mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-26 15:40:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39beb9cbb3 | ||
|
|
17250a20aa | ||
|
|
d0c84c87ba | ||
|
|
c5285bc293 | ||
|
|
735fe0b345 | ||
|
|
c28e6a7067 | ||
|
|
781b1854eb | ||
|
|
2e9ac89591 | ||
|
|
78f0b7e681 | ||
|
|
65674dcd38 | ||
|
|
1606ab6f5a | ||
|
|
daf9075b2f | ||
|
|
28f1d51cb6 | ||
|
|
ce1ff95f4c | ||
|
|
d4ab4d1aef | ||
|
|
14b8dfa3e8 | ||
|
|
be9e525739 | ||
|
|
d8a8fe9a80 | ||
|
|
4b1ff5a4bb | ||
|
|
6d296b1b34 | ||
|
|
1838b633e5 | ||
|
|
aee7d32af6 | ||
|
|
875c82e833 | ||
|
|
fcbb820949 | ||
|
|
f3d9edb57f | ||
|
|
daf2a2686b | ||
|
|
35efd36c5c | ||
|
|
6c769554a3 | ||
|
|
e625c33bef | ||
|
|
ec1d1c827b | ||
|
|
58d1a8a869 | ||
|
|
5370b46c65 | ||
|
|
e3961ee70b | ||
|
|
e6b6086e31 | ||
|
|
4814e82055 | ||
|
|
d59ccd1d8e | ||
|
|
7bc5ed7e4d | ||
|
|
2a37e2c5d7 | ||
|
|
c003af294a | ||
|
|
0e7adb38fa | ||
|
|
897024a297 | ||
|
|
f081f9d67e | ||
|
|
cae025c3fb | ||
|
|
4bb1e060c0 | ||
|
|
ca4d9eaaf9 | ||
|
|
fef7d45208 | ||
|
|
2d9f5d9e4b | ||
|
|
e920416114 | ||
|
|
87a5646ad0 | ||
|
|
57f19fb149 | ||
|
|
96dcb54429 | ||
|
|
490a54f922 | ||
|
|
7d59a757fb | ||
|
|
5290792dff | ||
|
|
990276c214 | ||
|
|
dcb5113f9d | ||
|
|
70cbbb4ef4 | ||
|
|
28934350ef | ||
|
|
092bf8defa | ||
|
|
13cb8e7402 | ||
|
|
9f0ee544cf | ||
|
|
d39d297d92 | ||
|
|
eb83d556e8 | ||
|
|
f0a6c86caa | ||
|
|
9c16830fc0 | ||
|
|
194aa92fd2 | ||
|
|
a8312dfefc | ||
|
|
92b23a2018 | ||
|
|
8a70cc4d7b | ||
|
|
da23dd12f3 | ||
|
|
e52a34de1c | ||
|
|
30aa83c5b2 | ||
|
|
87ead9ddaf | ||
|
|
e7a0ce57aa | ||
|
|
06cd94b12d | ||
|
|
3938359828 | ||
|
|
a1a7c9b4c5 | ||
|
|
8d68130aac | ||
|
|
7784f7db3d | ||
|
|
da74aee2cd | ||
|
|
685363c5a8 | ||
|
|
1f9d8c039c | ||
|
|
ed5e3e53e2 | ||
|
|
98a0d8381b | ||
|
|
a2cb3f1889 | ||
|
|
e2aff13d3e | ||
|
|
2044d4db96 | ||
|
|
2d709a48e6 | ||
|
|
ab64ae7fe7 | ||
|
|
84db64e0d5 | ||
|
|
a2736cd2b7 | ||
|
|
acd21eae4c | ||
|
|
364d9b5a8a | ||
|
|
8a8b1c4b2b | ||
|
|
fc68acd0ab | ||
|
|
2e5efc6cc9 | ||
|
|
553129a981 | ||
|
|
ad719297c2 | ||
|
|
5f9276f5dc | ||
|
|
0c5a21e57d | ||
|
|
b7b47ffd79 | ||
|
|
8e41d14283 | ||
|
|
98ea3fa74e | ||
|
|
fa7d36da9b | ||
|
|
5ae6f3a56c | ||
|
|
eaf63db886 | ||
|
|
857caa1397 | ||
|
|
c751dfdebf | ||
|
|
2200a4ce8c | ||
|
|
e0e4789b8a | ||
|
|
deaaf8ad1a | ||
|
|
2c7e3f60cc | ||
|
|
c61ac39e8f | ||
|
|
c426b8c246 | ||
|
|
9130891029 | ||
|
|
3c3694dc7c | ||
|
|
e2261f531b | ||
|
|
2d7909e357 | ||
|
|
0b69ef93cf | ||
|
|
44a73982d4 | ||
|
|
b922b8ad0e | ||
|
|
c4a6dd48d3 | ||
|
|
8bd28507a9 | ||
|
|
0096b6b06f | ||
|
|
71275fc303 | ||
|
|
5d7e4fe90a | ||
|
|
f53c968d8b | ||
|
|
35641ac072 | ||
|
|
92fe6a1de9 | ||
|
|
40c5d5e351 | ||
|
|
90c738d83a | ||
|
|
8f606e4ff9 | ||
|
|
18eebdadaa | ||
|
|
4e448015f3 | ||
|
|
11073ac644 | ||
|
|
2ebbebac7f | ||
|
|
a10b7248a6 | ||
|
|
83662f71cb | ||
|
|
a844920489 | ||
|
|
57ace2009c | ||
|
|
921e4fc323 | ||
|
|
75e5901360 | ||
|
|
3ed82ae22c | ||
|
|
f35d109810 | ||
|
|
16a0a3481d | ||
|
|
2f0cf1db06 | ||
|
|
5c86b60cd2 | ||
|
|
98b59354a9 | ||
|
|
5747f561c0 | ||
|
|
c7246e48d9 | ||
|
|
953ea749bf | ||
|
|
7b079067b9 | ||
|
|
101082fa3b | ||
|
|
94aa6873f4 | ||
|
|
46f4788c28 | ||
|
|
d7988384d3 | ||
|
|
ce7169f61d | ||
|
|
42137e689b | ||
|
|
b629e0852f | ||
|
|
2c863ce9a6 | ||
|
|
cf6be5f58e | ||
|
|
a588ddc23c | ||
|
|
a6603e8b48 | ||
|
|
52c4826569 | ||
|
|
8a844285ff | ||
|
|
d48f8384ed | ||
|
|
601ba7db8d | ||
|
|
4c444c943e | ||
|
|
bad37a5a21 | ||
|
|
d8f4b09478 | ||
|
|
d09875a056 | ||
|
|
a6cb7563e5 | ||
|
|
ea910a8987 | ||
|
|
b223319183 | ||
|
|
d7403f999d | ||
|
|
316e4f8d74 | ||
|
|
1997f15a7a | ||
|
|
b4a2ed874f | ||
|
|
182a90973d | ||
|
|
2fdf02e0be | ||
|
|
a500781b6d | ||
|
|
c4cc0be4fe | ||
|
|
0572fec6a3 | ||
|
|
a259764458 | ||
|
|
c218054d05 | ||
|
|
e39d318732 | ||
|
|
d44a54628a | ||
|
|
b62d72a130 | ||
|
|
d6c8df45e1 | ||
|
|
3b1b6ff448 | ||
|
|
dffa1767f2 | ||
|
|
36101974cf | ||
|
|
6e9cb60fd5 | ||
|
|
027bcd39ce | ||
|
|
c8108f221e | ||
|
|
793325cb60 | ||
|
|
a4266be3da | ||
|
|
e83651b57b | ||
|
|
83c5416c82 | ||
|
|
d8e51f586f | ||
|
|
d8982e1e5a | ||
|
|
9f9cb6005d | ||
|
|
75be235d49 | ||
|
|
0a3a6d72cf | ||
|
|
9dfb562a4f | ||
|
|
3145e94797 | ||
|
|
afe1a2c92c | ||
|
|
8a0af6985f | ||
|
|
918ac6a87d | ||
|
|
b9ccd411ed | ||
|
|
6530e1f7aa | ||
|
|
0b753d25e1 | ||
|
|
dbcc5aff9b | ||
|
|
e204945b3d | ||
|
|
0435e36cad | ||
|
|
246f0dbbaf | ||
|
|
c240fd1ee8 | ||
|
|
7f8e95b334 | ||
|
|
01efcc8638 | ||
|
|
a8305079c3 | ||
|
|
90cfcf0c14 | ||
|
|
44a20f12a4 | ||
|
|
bf5737614d | ||
|
|
d69403ab6f | ||
|
|
51eacc5042 | ||
|
|
98488a00a2 | ||
|
|
4a081bcb71 | ||
|
|
209c985420 | ||
|
|
f494d2ce64 | ||
|
|
be110c4ba0 | ||
|
|
15cd10669d | ||
|
|
cd312de252 | ||
|
|
f292398e94 | ||
|
|
3d7c6118de | ||
|
|
2377c417e4 | ||
|
|
938b946e55 | ||
|
|
e6f069018c | ||
|
|
5d304e95ed | ||
|
|
30a9e0f7f5 | ||
|
|
d830e8de7c | ||
|
|
30a7d50a56 | ||
|
|
abcaf2b94a | ||
|
|
36cbd34ffc | ||
|
|
5bdb18761b | ||
|
|
5a99faf967 | ||
|
|
c409ee4d37 | ||
|
|
1d9333dcdc | ||
|
|
b5a5ea2a75 | ||
|
|
e90b14cd3b | ||
|
|
1c61280d70 | ||
|
|
ff499604f0 | ||
|
|
92647b7aad | ||
|
|
dd8472f639 | ||
|
|
b842ed478b | ||
|
|
b21bc89bd7 | ||
|
|
80f92b1dee |
+9
-1
@@ -1,3 +1,11 @@
|
|||||||
data/*
|
data/*
|
||||||
*.pyc
|
*.pyc
|
||||||
helpers/*.pyc
|
*.egg/
|
||||||
|
*.egg-info/
|
||||||
|
.cache/
|
||||||
|
.coverage
|
||||||
|
.eggs/
|
||||||
|
build/
|
||||||
|
coverage.xml
|
||||||
|
junit.xml
|
||||||
|
pgpass
|
||||||
|
|||||||
+3
-2
@@ -13,8 +13,9 @@ RUN apt-get update -y
|
|||||||
RUN apt-get upgrade -y
|
RUN apt-get upgrade -y
|
||||||
|
|
||||||
ENV PGVERSION 9.4
|
ENV PGVERSION 9.4
|
||||||
RUN apt-get install python python-psycopg2 python-yaml python-requests python-boto postgresql-${PGVERSION} python-dnspython python-kazoo python-pip -y
|
RUN apt-get install python python-yaml python-requests python-boto postgresql-${PGVERSION} python-dnspython python-kazoo python-pip -y
|
||||||
RUN pip install python-etcd
|
RUN apt-get install python-dev postgresql-server-dev-${PGVERSION} -y
|
||||||
|
RUN pip install python-etcd psycopg2
|
||||||
|
|
||||||
ENV PATH /usr/lib/postgresql/${PGVERSION}/bin:$PATH
|
ENV PATH /usr/lib/postgresql/${PGVERSION}/bin:$PATH
|
||||||
|
|
||||||
|
|||||||
+81
-102
@@ -5,10 +5,10 @@ Patroni: A Template for PostgreSQL HA with ZooKeeper or etcd
|
|||||||
|
|
||||||
Patroni was previously known as Governor.
|
Patroni was previously known as Governor.
|
||||||
|
|
||||||
*There are many ways to run high availability with PostgreSQL; here we
|
*There are many ways to run high availability with PostgreSQL. Here, we
|
||||||
present a template for you to create your own custom fit high
|
present a template for you to create your own customized, high-availability
|
||||||
availability solution using python and distributed configuration store
|
solution using Python and — for maximum accessibility — a distributed
|
||||||
(like ZooKeeper or etcd) for maximum accessibility.*
|
configuration store like ZooKeeper or etcd.*
|
||||||
|
|
||||||
Getting Started
|
Getting Started
|
||||||
---------------
|
---------------
|
||||||
@@ -22,8 +22,8 @@ To get started, do the following from different terminals:
|
|||||||
> ./patroni.py postgres1.yml
|
> ./patroni.py postgres1.yml
|
||||||
|
|
||||||
From there, you will see a high-availability cluster start up. Test
|
From there, you will see a high-availability cluster start up. Test
|
||||||
different settings in the YAML files to see how behavior changes. Kill
|
different settings in the YAML files to see how its behavior changes. Kill
|
||||||
some of the different components to see how the system behaves.
|
some of the components to see how the system behaves.
|
||||||
|
|
||||||
Add more ``postgres*.yml`` files to create an even larger cluster.
|
Add more ``postgres*.yml`` files to create an even larger cluster.
|
||||||
|
|
||||||
@@ -39,109 +39,89 @@ run:
|
|||||||
|
|
||||||
> psql --host 127.0.0.1 --port 5000 postgres
|
> psql --host 127.0.0.1 --port 5000 postgres
|
||||||
|
|
||||||
How Patroni works
|
How Patroni Works
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
For a diagram of the high availability decision loop, see the included a
|
For a diagram of the high availability decision loop, review this PDF:
|
||||||
PDF:
|
|
||||||
`postgres-ha.pdf <https://github.com/zalando/patroni/blob/master/postgres-ha.pdf>`__
|
`postgres-ha.pdf <https://github.com/zalando/patroni/blob/master/postgres-ha.pdf>`__
|
||||||
|
|
||||||
YAML Configuration
|
YAML Configuration
|
||||||
------------------
|
------------------
|
||||||
|
|
||||||
For an example file, see ``postgres0.yml``. Below is an explanation of
|
For an example file, see ``postgres0.yml``. Regarding settings:
|
||||||
settings:
|
|
||||||
|
|
||||||
- *ttl*: the TTL to acquire the leader lock. Think of it as the length
|
- *ttl*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
|
||||||
of time before automatic failover process is initiated.
|
|
||||||
- *loop\_wait*: the number of seconds the loop will sleep
|
- *loop\_wait*: the number of seconds the loop will sleep
|
||||||
|
|
||||||
- *restapi*
|
- *restapi*:
|
||||||
- *listen*: ip address + port that Patroni will listen to provide
|
- *listen*: IP address + port that Patroni will listen to, to provide health-check information for haproxy.
|
||||||
health-check information for haproxy.
|
- *connect\_address*: IP address + port through which restapi is accessible.
|
||||||
- *connect\_address*: ip address + port through which restapi is
|
- *auth*: (optional) 'username:password' to protect dangerous REST API endpoints.
|
||||||
accessible.
|
- *certfile*: (optional) Specifies a file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
|
||||||
|
- *keyfile*: (optional) Specifies a file with the secret key in the PEM format.
|
||||||
|
|
||||||
- *etcd*
|
- *etcd*:
|
||||||
- *scope*: the relative path used on etcd's http api for this
|
- *scope*: the relative path used on etcd's HTTP API for this deployment; makes it possible to run multiple HA deployments from a single etcd.
|
||||||
deployment, thus you can run multiple HA deployments from a single
|
- *ttl*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
|
||||||
etcd
|
- *host*: the host:port for the etcd endpoint.
|
||||||
- *ttl*: the TTL to acquire the leader lock. Think of it as the length
|
|
||||||
of time before automatic failover process is initiated.
|
|
||||||
- *host*: the host:port for the etcd endpoint
|
|
||||||
|
|
||||||
- *zookeeper*
|
- *zookeeper*:
|
||||||
- *scope*: the relative path used on etcd's http api for this
|
- *scope*: the relative path used on etcd's HTTP API for this deployment; makes it possible to run multiple HA deployments from a single etcd.
|
||||||
deployment, thus you can run multiple HA deployments from a single
|
- *session\_timeout*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
|
||||||
etcd
|
- *reconnect\_timeout*: how long we should try to reconnect to ZooKeeper after a connection loss. After this timeout, assume that you no longer have a lock and restart in read-only mode.
|
||||||
- *session\_timeout*: the TTL to acquire the leader lock. Think of it
|
- *hosts*: list of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...']
|
||||||
as the length of time before automatic failover process is initiated.
|
- *exhibitor*: if you are running a ZooKeeper cluster under the Exhibitor supervisory, the following section might interest you:
|
||||||
- *reconnect\_timeout*: how long we should try to reconnect to
|
- *poll\_interval*: how often the list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor
|
||||||
ZooKeeper after connection loss. After this timeout we assume that we
|
- *port*: Exhibitor port.
|
||||||
don't have lock anymore and will restart in read-only mode.
|
- *hosts*: initial list of Exhibitor (ZooKeeper) nodes in format: ['host1', 'host2', 'etc...' ]. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
|
||||||
- *hosts*: list of ZooKeeper cluster members in format: [
|
|
||||||
'host1:port1', 'host2:port2', 'etc...']
|
|
||||||
- *exhibitor*: if you are running ZooKeeper cluster under Exhibitor
|
|
||||||
supervisory the following section could be interesting for you
|
|
||||||
|
|
||||||
- *poll\_interval*: how often list of ZooKeeper and Exhibitor nodes
|
- *postgresql*:
|
||||||
should be updated from Exhibitor
|
- *name*: the name of the Postgres host. Must be unique for the cluster.
|
||||||
- *port*: Exhibitor port
|
- *listen*: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication.
|
||||||
- *hosts*: initial list of Exhibitor (ZooKeeper) nodes in format: [
|
- *connect\_address*: IP address + port through which Postgres is accessible from other nodes and applications.
|
||||||
'host1', 'host2', 'etc...' ]. This list would be updated
|
- *data\_dir*: file path to initialize and store Postgres data files.
|
||||||
automatically when Exhibitor (ZooKeeper) cluster topology changes.
|
- *maximum\_lag\_on\_failover*: the maximum bytes a follower may lag.
|
||||||
|
- *use\_slots*: whether or not to use replication_slots. Must be False for PostgreSQL 9.3. You should comment out max_replication_slots before it becomes ineligible for leader status.
|
||||||
|
|
||||||
- *postgresql*
|
- *initdb*: List options to be passed on to initdb
|
||||||
- *name*: the name of the Postgres host, must be unique for the cluster
|
- *encoding*: default encoding for new databases
|
||||||
- *listen*: ip address + port that Postgres listening. Must be
|
- *locale*: default locale for new databases
|
||||||
accessible from other nodes in the cluster if using streaming
|
- *data-checksums* # When pg_rewind is needed on 9.3, this needs to be enabled
|
||||||
replication.
|
|
||||||
- *connect\_address*: ip address + port through which Postgres is
|
|
||||||
accessible from other nodes and applications.
|
|
||||||
- *data\_dir*: file path to initialize and store Postgres data files
|
|
||||||
- *maximum\_lag\_on\_failover*: the maximum bytes a follower may lag
|
|
||||||
- *use\_slots*: whether or not to use replication_slots. Must be False for PostgreSQL 9.3, and you should comment out max_replication_slots.
|
|
||||||
before it is not eligible become leader
|
|
||||||
- *pg\_hba*: list of lines which should be added to pg\_hba.conf
|
|
||||||
|
|
||||||
- *- host all all 0.0.0.0/0 md5*
|
- *pg\_hba*: list of lines which should be added to pg\_hba.conf.
|
||||||
|
- *- host all all 0.0.0.0/0 md5*.
|
||||||
|
|
||||||
- *replication*
|
- *replication*:
|
||||||
|
- *username*: replication username; user will be created during initialization.
|
||||||
|
- *password*: replication password; user will be created during initialization.
|
||||||
|
- *network*: network setting for replication in pg\_hba.conf.
|
||||||
|
|
||||||
- *username*: replication username, user will be created during
|
- *callbacks* callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. See scripts/aws.py as an example on how to write them.
|
||||||
initialization
|
- *on\_start*: a script to run when the cluster starts.
|
||||||
- *password*: replication password, user will be created during
|
- *on\_stop*: a script to run when the cluster stops.
|
||||||
initialization
|
- *on\_restart*: a script to run when the cluster restarts.
|
||||||
- *network*: network setting for replication in pg\_hba.conf
|
- *on\_reload*: a script to run when configuration reload is triggered.
|
||||||
|
- *on\_role\_change*: a script to run when the cluster is being promoted or demoted.
|
||||||
|
|
||||||
- *callbacks* callback scripts to run on certain actions. Patroni will
|
- *superuser*:
|
||||||
pass current action, role and cluster name. See scripts/aws.py as an
|
- *password*: password for the Postgres user, set during initialization.
|
||||||
example on how to write them.
|
|
||||||
|
|
||||||
- *on\_start*: a script to run when the cluster starts
|
- *admin*:
|
||||||
- *on\_stop*: a script to run when the cluster stops
|
- *username*: admin username; user is created during initialization. It will have CREATEDB and CREATEROLE privileges.
|
||||||
- *on\_restart*: a script to run when the cluster restarts
|
- *password*: admin password; user is created during initialization.
|
||||||
- *on\_reload*: a script to run when configuration reload is
|
|
||||||
triggered
|
|
||||||
- *on\_role\_change*: a script to run when the cluster is being
|
|
||||||
promoted or demoted
|
|
||||||
|
|
||||||
- *superuser*
|
- *recovery\_conf*: additional configuration settings written to recovery.conf when configuring follower.
|
||||||
|
- *parameters*: list of configuration settings for Postgres. Many of these are required for replication to work.
|
||||||
|
|
||||||
- *password*: password for postgres user. It would be set during
|
- *create\_replica\_methods*: an ordered list of the create methods for turning a patroni node into a new replica.
|
||||||
initialization
|
"basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured
|
||||||
|
as its own config item.
|
||||||
|
|
||||||
- *admin*:
|
- *replica\_method* for each create_replica_method other than basebackup, you would add a configuration section
|
||||||
|
of the same name. At a minimum, this should include "command" with a full path to the actual script to be
|
||||||
|
executed. Other configuration parameters will be passed along to the script in the form "parameter=value".
|
||||||
|
|
||||||
- *username*: admin username, user will be created during
|
Replication Choices
|
||||||
initialization. It would have CREATEDB and CREATEROLE privileges
|
|
||||||
- *password*: admin password, user will be created during
|
|
||||||
initialization.
|
|
||||||
|
|
||||||
- *recovery\_conf*: additional configuration settings written to recovery.conf when configuring follower
|
|
||||||
- *parameters*: list of configuration settings for Postgres. Many of these are required for replication to work.
|
|
||||||
|
|
||||||
Replication choices
|
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
Patroni uses Postgres' streaming replication. By default, this
|
Patroni uses Postgres' streaming replication. By default, this
|
||||||
@@ -155,43 +135,43 @@ not occur if a follower is more than a certain number of bytes behind
|
|||||||
the follower. This setting should be increased or decreased based on
|
the follower. This setting should be increased or decreased based on
|
||||||
business requirements.
|
business requirements.
|
||||||
|
|
||||||
When asynchronous replication is not best for your use-case, investigate
|
When asynchronous replication is not optimal for your use case, investigate
|
||||||
how Postgres's `synchronous
|
how Postgres's `synchronous
|
||||||
replication <http://www.postgresql.org/docs/current/static/warm-standby.html#SYNCHRONOUS-REPLICATION>`__
|
replication <http://www.postgresql.org/docs/current/static/warm-standby.html#SYNCHRONOUS-REPLICATION>`__
|
||||||
works. Synchronous replication ensures consistency across a cluster by
|
works. Synchronous replication ensures consistency across a cluster by
|
||||||
confirming that writes are written to a secondary before returning to
|
confirming that writes are written to a secondary before returning to
|
||||||
the connecting client with a success. The cost of synchronous
|
the connecting client with a success. The cost of synchronous
|
||||||
replication will be reduced throughput on writes. This throughput will
|
replication: reduced throughput on writes. This throughput will
|
||||||
be entirely based on network performance. In hosted datacenter
|
be entirely based on network performance. In hosted datacenter
|
||||||
environments (like AWS, Rackspace, or any network you do not control),
|
environments (like AWS, Rackspace, or any network you do not control),
|
||||||
synchrous replication increases the variability of write performance
|
synchrous replication significantly increases the variability of write
|
||||||
significantly. If followers become inaccessible from the leader, the
|
performance. If followers become inaccessible from the leader, the
|
||||||
leader will becomes effectively readonly.
|
leader effectively becomes readonly.
|
||||||
|
|
||||||
To enable a simple synchronous replication test, add the follow lines to
|
To enable a simple synchronous replication test, add the follow lines to
|
||||||
the ``parameters`` section of your YAML configuration files.
|
the ``parameters`` section of your YAML configuration files:
|
||||||
|
|
||||||
.. code:: YAML
|
.. code:: YAML
|
||||||
|
|
||||||
synchronous_commit: "on"
|
synchronous_commit: "on"
|
||||||
synchronous_standby_names: "*"
|
synchronous_standby_names: "*"
|
||||||
|
|
||||||
When using synchronous replication, use at least a 3-Postgres data nodes
|
When using synchronous replication, use at least three Postgres data nodes
|
||||||
to ensure write availability if one host fails.
|
to ensure write availability if one host fails.
|
||||||
|
|
||||||
Choosing your replication schema is dependent on the many business
|
Choosing your replication schema is dependent on your business
|
||||||
decisions. Investigate both async and sync replication, as well as other
|
considerations. Investigate both async and sync replication, as well as other
|
||||||
HA solutions, to determine which solution is best for you.
|
HA solutions, to determine which solution is best for you.
|
||||||
|
|
||||||
Applications should not use superusers
|
Applications Should Not Use Superusers
|
||||||
--------------------------------------
|
--------------------------------------
|
||||||
|
|
||||||
When connecting from an application, always use a non-superuser. Patroni
|
When connecting from an application, always use a non-superuser. Patroni
|
||||||
requires access to the database to function properly. By using a
|
requires access to the database to function properly. By using a
|
||||||
superuser from application, you can potentially use the entire
|
superuser from an application, you can potentially use the entire
|
||||||
connection pool, including the connections reserved for superusers with
|
connection pool, including the connections reserved for superusers with
|
||||||
the ``superuser_reserved_connections`` setting. If Patroni cannot access
|
the ``superuser_reserved_connections`` setting. If Patroni cannot access
|
||||||
the Primary, because the connection pool is full, behavior will be
|
the Primary because the connection pool is full, behavior will be
|
||||||
undesireable.
|
undesireable.
|
||||||
|
|
||||||
Requirements on a Mac
|
Requirements on a Mac
|
||||||
@@ -207,14 +187,13 @@ Run the following on a Mac to install requirements:
|
|||||||
Notice
|
Notice
|
||||||
------
|
------
|
||||||
|
|
||||||
There are many different ways to do HA with PostgreSQL, see `the
|
There are many different ways to do HA with PostgreSQL: See `the
|
||||||
PostgreSQL
|
PostgreSQL
|
||||||
documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__
|
documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__
|
||||||
for a complete list.
|
for a complete list.
|
||||||
|
|
||||||
We call this project a "template" because it is far from a one-size fits
|
We call Patroni a "template" because it is far from being a one-size-fits-all
|
||||||
all, or a plug-and-play replication system. It will have it's own
|
or plug-and-play replication system. It will have its own caveats. Use wisely.
|
||||||
caveats. Use wisely.
|
|
||||||
|
|
||||||
.. |Build Status| image:: https://travis-ci.org/zalando/patroni.svg?branch=master
|
.. |Build Status| image:: https://travis-ci.org/zalando/patroni.svg?branch=master
|
||||||
:target: https://travis-ci.org/zalando/patroni
|
:target: https://travis-ci.org/zalando/patroni
|
||||||
|
|||||||
+6
-6
@@ -1,7 +1,7 @@
|
|||||||
# Patroni Dockerfile
|
# Patroni Dockerfile
|
||||||
You can run Patroni in a docker container using this Dockerfile, or by using one of the Docker image at
|
You can run Patroni in a docker container using this Dockerfile, or by using one of the Docker image at
|
||||||
|
|
||||||
https://os-registry.stups.zalan.do/v1/repositories/acid/patroni/tags
|
https://registry.opensource.zalan.do/v1/repositories/acid/patroni/tags
|
||||||
|
|
||||||
This Dockerfile is meant in aiding development of Patroni and quick testing of features. It is not a production-worthy
|
This Dockerfile is meant in aiding development of Patroni and quick testing of features. It is not a production-worthy
|
||||||
Dockerfile
|
Dockerfile
|
||||||
@@ -10,7 +10,7 @@ Dockerfile
|
|||||||
|
|
||||||
## Standalone Patroni
|
## Standalone Patroni
|
||||||
|
|
||||||
docker run -d os-registry.stups.zalan.do/acid/patroni:1.0-SNAPSHOT
|
docker run -d registry.opensource.zalan.do/acid/patroni:1.0-SNAPSHOT
|
||||||
|
|
||||||
## Multiple Patroni's communicating with a standalone etcd inside Docker
|
## Multiple Patroni's communicating with a standalone etcd inside Docker
|
||||||
|
|
||||||
@@ -36,12 +36,12 @@ To automate this you can run the following script:
|
|||||||
|
|
||||||
Example session:
|
Example session:
|
||||||
|
|
||||||
$ ./dev_patroni_cluster.sh --image os-registry.stups.zalan.do/acid/patroni:1.0-SNAPSHOT --members=2 --name=bravo
|
$ ./dev_patroni_cluster.sh --image registry.opensource.zalan.do/acid/patroni:1.0-SNAPSHOT --members=2 --name=bravo
|
||||||
The etcd container is 6be871a11cb373406ca5ea1c6b39e1.0-SNAPSHOTfdde9fb1d6177212d6ad0c0d1bd9b563, ip=172.17.1.24
|
The etcd container is 6be871a11cb373406ca5ea1c6b39e1.0-SNAPSHOTfdde9fb1d6177212d6ad0c0d1bd9b563, ip=172.17.1.24
|
||||||
Started Patroni container 67e611f2eca7c40f9e6e0e24a4a8f2cba7e3e56d22a420e15ab9240a37a9d7a4, ip=172.17.1.25
|
Started Patroni container 67e611f2eca7c40f9e6e0e24a4a8f2cba7e3e56d22a420e15ab9240a37a9d7a4, ip=172.17.1.25
|
||||||
Started Patroni container 47dd12ae635ab83b039f5889e250048b606ed5e48e3650b69e365e7e1d4acbcf, ip=172.17.1.26
|
Started Patroni container 47dd12ae635ab83b039f5889e250048b606ed5e48e3650b69e365e7e1d4acbcf, ip=172.17.1.26
|
||||||
$ docker ps
|
$ docker ps
|
||||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||||
47dd12ae635a os-registry.stups.zalan.do/acid/patroni:1.0-SNAPSHOT "/bin/bash /entrypoi 10 seconds ago Up 8 seconds 4001/tcp, 5432/tcp, 2380/tcp bravo_OR64g8bx
|
47dd12ae635a registry.opensource.zalan.do/acid/patroni:1.0-SNAPSHOT "/bin/bash /entrypoi 10 seconds ago Up 8 seconds 4001/tcp, 5432/tcp, 2380/tcp bravo_OR64g8bx
|
||||||
67e611f2eca7 os-registry.stups.zalan.do/acid/patroni:1.0-SNAPSHOT "/bin/bash /entrypoi 11 seconds ago Up 10 seconds 2380/tcp, 4001/tcp, 5432/tcp bravo_si9no8iz
|
67e611f2eca7 registry.opensource.zalan.do/acid/patroni:1.0-SNAPSHOT "/bin/bash /entrypoi 11 seconds ago Up 10 seconds 2380/tcp, 4001/tcp, 5432/tcp bravo_si9no8iz
|
||||||
6be871a11cb3 os-registry.stups.zalan.do/acid/patroni:1.0-SNAPSHOT "/bin/bash /entrypoi 12 seconds ago Up 10 seconds 4001/tcp, 5432/tcp, 2380/tcp bravo_etcd
|
6be871a11cb3 registry.opensource.zalan.do/acid/patroni:1.0-SNAPSHOT "/bin/bash /entrypoi 12 seconds ago Up 10 seconds 4001/tcp, 5432/tcp, 2380/tcp bravo_etcd
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
DOCKER_IMAGE="os-registry.stups.zalan.do/acid/patroni:1.0-SNAPSHOT"
|
DOCKER_IMAGE="registry.opensource.zalan.do/acid/patroni:1.0-SNAPSHOT"
|
||||||
MEMBERS=3
|
MEMBERS=3
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
-11
@@ -3,25 +3,25 @@
|
|||||||
function usage()
|
function usage()
|
||||||
{
|
{
|
||||||
cat <<__EOF__
|
cat <<__EOF__
|
||||||
Usage: $0
|
Usage: $0
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
|
||||||
--etcd ETCD Provide an external etcd to connect to
|
--etcd ETCD Provide an external etcd to connect to
|
||||||
--name NAME Give the cluster a specific name
|
--name NAME Give the cluster a specific name
|
||||||
--etcd-only Do not run Patroni, run a standalone etcd
|
--etcd-only Do not run Patroni, run a standalone etcd
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
$0 --etcd=127.17.0.84:4001
|
$0 --etcd=127.17.0.84:4001
|
||||||
$0 --etcd-only
|
$0 --etcd-only
|
||||||
$0
|
$0
|
||||||
$0 --name=true_scotsman
|
$0 --name=true_scotsman
|
||||||
__EOF__
|
__EOF__
|
||||||
}
|
}
|
||||||
|
|
||||||
DOCKER_IP=$(hostname --ip-address)
|
DOCKER_IP=$(hostname --ip-address)
|
||||||
PATRONI_SCOPE=batman
|
PATRONI_SCOPE=${PATRONI_SCOPE:-batman}
|
||||||
|
|
||||||
optspec=":vh-:"
|
optspec=":vh-:"
|
||||||
while getopts "$optspec" optchar; do
|
while getopts "$optspec" optchar; do
|
||||||
@@ -32,7 +32,7 @@ while getopts "$optspec" optchar; do
|
|||||||
exec etcd --data-dir /tmp/etcd.data \
|
exec etcd --data-dir /tmp/etcd.data \
|
||||||
-advertise-client-urls=http://${DOCKER_IP}:4001 \
|
-advertise-client-urls=http://${DOCKER_IP}:4001 \
|
||||||
-listen-client-urls=http://0.0.0.0:4001 \
|
-listen-client-urls=http://0.0.0.0:4001 \
|
||||||
-listen-peer-urls=http://0.0.0.0:2380
|
-listen-peer-urls=http://0.0.0.0:2380
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
cheat)
|
cheat)
|
||||||
@@ -83,16 +83,17 @@ cat > /patroni/postgres.yml <<__EOF__
|
|||||||
|
|
||||||
ttl: &ttl 30
|
ttl: &ttl 30
|
||||||
loop_wait: &loop_wait 10
|
loop_wait: &loop_wait 10
|
||||||
scope: &scope ${PATRONI_SCOPE}
|
scope: &scope '${PATRONI_SCOPE}'
|
||||||
|
namespace: 'patroni'
|
||||||
restapi:
|
restapi:
|
||||||
listen: 127.0.0.1:8008
|
listen: 0.0.0.0:8008
|
||||||
connect_address: 127.0.0.1:8008
|
connect_address: ${DOCKER_IP}:8008
|
||||||
etcd:
|
etcd:
|
||||||
scope: *scope
|
scope: *scope
|
||||||
ttl: *ttl
|
ttl: *ttl
|
||||||
host: ${ETCD_CLUSTER}
|
host: ${ETCD_CLUSTER}
|
||||||
postgresql:
|
postgresql:
|
||||||
name: postgresql_${DOCKER_IP//./_} ## Replication slots do not allow dots in their name
|
name: ${HOSTNAME}
|
||||||
scope: *scope
|
scope: *scope
|
||||||
listen: 0.0.0.0:5432
|
listen: 0.0.0.0:5432
|
||||||
connect_address: ${DOCKER_IP}:5432
|
connect_address: ${DOCKER_IP}:5432
|
||||||
@@ -115,10 +116,11 @@ postgresql:
|
|||||||
parameters:
|
parameters:
|
||||||
archive_mode: "on"
|
archive_mode: "on"
|
||||||
wal_level: hot_standby
|
wal_level: hot_standby
|
||||||
archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f
|
archive_command: 'true'
|
||||||
max_wal_senders: 20
|
max_wal_senders: 20
|
||||||
listen_addresses: 0.0.0.0
|
listen_addresses: 0.0.0.0
|
||||||
wal_keep_segments: 8
|
checkpoint_segments: 64
|
||||||
|
wal_keep_segments: 64
|
||||||
archive_timeout: 1800s
|
archive_timeout: 1800s
|
||||||
max_replication_slots: 20
|
max_replication_slots: 20
|
||||||
hot_standby: "on"
|
hot_standby: "on"
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# startup scripts for Patroni
|
||||||
|
|
||||||
|
This directory contains sample startup scripts for various OSes
|
||||||
|
and management tools for Patroni.
|
||||||
|
|
||||||
|
Scripts supplied:
|
||||||
|
|
||||||
|
### patroni.upstart.conf
|
||||||
|
|
||||||
|
Upstart job for Ubuntu 12.04 or 14.04. Requires Upstart > 1.4. Intended for systems where Patroni has been installed on a base system, rather than in Docker.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# patroni - patroni daemon
|
||||||
|
#
|
||||||
|
# controls startup/shutdown of postgres
|
||||||
|
# you should disable any postgres start jobs
|
||||||
|
#
|
||||||
|
# assumes that patroni has been installed into the
|
||||||
|
# pythonpath by using setup.py install
|
||||||
|
|
||||||
|
description "patroni start daemon"
|
||||||
|
|
||||||
|
start on net-device-up
|
||||||
|
stop on runlevel [06]
|
||||||
|
|
||||||
|
respawn
|
||||||
|
respawn limit 5 10
|
||||||
|
|
||||||
|
# set location of patroni
|
||||||
|
env PATRONI=/usr/local/bin/patroni
|
||||||
|
# virtualenv example
|
||||||
|
# env PATRONI=/var/lib/postgresql/patronienv/bin/patroni
|
||||||
|
|
||||||
|
# set location of config file
|
||||||
|
env PATRONICONF=/etc/patroni/patroni.yml
|
||||||
|
|
||||||
|
# set log dir for patroni logs
|
||||||
|
# postgres user must have write permission
|
||||||
|
env POSTGRESLOGDIR=/var/log/postgresql
|
||||||
|
|
||||||
|
setuid postgres
|
||||||
|
setgid postgres
|
||||||
|
|
||||||
|
script
|
||||||
|
exec start-stop-daemon --start \
|
||||||
|
--exec $PATRONI -- $PATRONICONF \
|
||||||
|
>> $POSTGRESLOGDIR/patroni.log 2>&1
|
||||||
|
end script
|
||||||
+3
-3
@@ -15,7 +15,7 @@ frontend ft_postgresql
|
|||||||
default_backend bk_db
|
default_backend bk_db
|
||||||
|
|
||||||
backend bk_db
|
backend bk_db
|
||||||
option httpchk GET
|
option httpchk
|
||||||
|
|
||||||
server postgresql_127.0.0.1_5432 127.0.0.1:5432 maxconn 100 check port 8008
|
server postgresql_127.0.0.1_5432 127.0.0.1:5432 maxconn 100 check port 8008
|
||||||
server postgresql_127.0.0.1_5433 127.0.0.1:5433 maxconn 100 check port 8009
|
server postgresql_127.0.0.1_5433 127.0.0.1:5433 maxconn 100 check port 8009
|
||||||
|
|||||||
+13
-55
@@ -8,7 +8,7 @@ from patroni.api import RestApiServer
|
|||||||
from patroni.etcd import Etcd
|
from patroni.etcd import Etcd
|
||||||
from patroni.ha import Ha
|
from patroni.ha import Ha
|
||||||
from patroni.postgresql import Postgresql
|
from patroni.postgresql import Postgresql
|
||||||
from patroni.utils import setup_signal_handlers, sleep, reap_children
|
from patroni.utils import setup_signal_handlers, reap_children
|
||||||
from patroni.zookeeper import ZooKeeper
|
from patroni.zookeeper import ZooKeeper
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -18,12 +18,16 @@ class Patroni:
|
|||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self.nap_time = config['loop_wait']
|
self.nap_time = config['loop_wait']
|
||||||
|
self.tags = config.get('tags', dict())
|
||||||
self.postgresql = Postgresql(config['postgresql'])
|
self.postgresql = Postgresql(config['postgresql'])
|
||||||
self.ha = Ha(self.postgresql, self.get_dcs(self.postgresql.name, config))
|
self.dcs = self.get_dcs(self.postgresql.name, config)
|
||||||
host, port = config['restapi']['listen'].split(':')
|
|
||||||
self.api = RestApiServer(self, config['restapi'])
|
self.api = RestApiServer(self, config['restapi'])
|
||||||
|
self.ha = Ha(self)
|
||||||
self.next_run = time.time()
|
self.next_run = time.time()
|
||||||
self.shutdown_member_ttl = 300
|
|
||||||
|
@property
|
||||||
|
def nofailover(self):
|
||||||
|
return self.tags.get('nofailover', False)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_dcs(name, config):
|
def get_dcs(name, config):
|
||||||
@@ -31,68 +35,23 @@ class Patroni:
|
|||||||
return Etcd(name, config['etcd'])
|
return Etcd(name, config['etcd'])
|
||||||
if 'zookeeper' in config:
|
if 'zookeeper' in config:
|
||||||
return ZooKeeper(name, config['zookeeper'])
|
return ZooKeeper(name, config['zookeeper'])
|
||||||
raise Exception('Can not find sutable configuration of distributed configuration store')
|
raise Exception('Can not find suitable configuration of distributed configuration store')
|
||||||
|
|
||||||
def touch_member(self, ttl=None):
|
|
||||||
connection_string = self.postgresql.connection_string + '?application_name=' + self.api.connection_string
|
|
||||||
if self.ha.cluster:
|
|
||||||
for m in self.ha.cluster.members:
|
|
||||||
# Do not update member TTL when it is far from being expired
|
|
||||||
if m.name == self.postgresql.name and m.real_ttl() > self.shutdown_member_ttl:
|
|
||||||
return True
|
|
||||||
return self.ha.dcs.touch_member(connection_string, ttl)
|
|
||||||
|
|
||||||
def initialize(self):
|
|
||||||
# wait for etcd to be available
|
|
||||||
while not self.touch_member():
|
|
||||||
logger.info('waiting on DCS')
|
|
||||||
sleep(5)
|
|
||||||
|
|
||||||
# is data directory empty?
|
|
||||||
if self.postgresql.data_directory_empty():
|
|
||||||
# racing to initialize
|
|
||||||
if self.ha.dcs.race('/initialize'):
|
|
||||||
self.postgresql.initialize()
|
|
||||||
self.ha.dcs.take_leader()
|
|
||||||
self.postgresql.start()
|
|
||||||
self.postgresql.create_replication_user()
|
|
||||||
self.postgresql.create_connection_users()
|
|
||||||
else:
|
|
||||||
while True:
|
|
||||||
leader = self.ha.dcs.current_leader()
|
|
||||||
if leader and self.postgresql.sync_from_leader(leader):
|
|
||||||
self.postgresql.write_recovery_conf(leader)
|
|
||||||
self.postgresql.start()
|
|
||||||
break
|
|
||||||
sleep(5)
|
|
||||||
elif self.postgresql.is_running():
|
|
||||||
self.postgresql.load_replication_slots()
|
|
||||||
|
|
||||||
def schedule_next_run(self):
|
def schedule_next_run(self):
|
||||||
if self.postgresql.is_promoted:
|
|
||||||
self.next_run = time.time()
|
|
||||||
self.next_run += self.nap_time
|
self.next_run += self.nap_time
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
nap_time = self.next_run - current_time
|
nap_time = self.next_run - current_time
|
||||||
if nap_time <= 0:
|
if nap_time <= 0:
|
||||||
self.next_run = current_time
|
self.next_run = current_time
|
||||||
else:
|
elif self.dcs.watch(nap_time):
|
||||||
self.ha.dcs.watch(nap_time)
|
self.next_run = time.time()
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
self.api.start()
|
self.api.start()
|
||||||
self.next_run = time.time()
|
self.next_run = time.time()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
self.touch_member()
|
|
||||||
logger.info(self.ha.run_cycle())
|
logger.info(self.ha.run_cycle())
|
||||||
try:
|
|
||||||
if self.ha.state_handler.is_leader():
|
|
||||||
self.ha.cluster and self.ha.state_handler.create_replication_slots(self.ha.cluster)
|
|
||||||
else:
|
|
||||||
self.ha.state_handler.drop_replication_slots()
|
|
||||||
except:
|
|
||||||
logger.exception('Exception when changing replication slots')
|
|
||||||
reap_children()
|
reap_children()
|
||||||
self.schedule_next_run()
|
self.schedule_next_run()
|
||||||
|
|
||||||
@@ -111,11 +70,10 @@ def main():
|
|||||||
|
|
||||||
patroni = Patroni(config)
|
patroni = Patroni(config)
|
||||||
try:
|
try:
|
||||||
patroni.initialize()
|
|
||||||
patroni.run()
|
patroni.run()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
patroni.touch_member(patroni.shutdown_member_ttl) # schedule member removal
|
patroni.api.shutdown()
|
||||||
patroni.postgresql.stop()
|
patroni.postgresql.stop()
|
||||||
patroni.ha.dcs.delete_leader()
|
patroni.dcs.delete_leader()
|
||||||
|
|||||||
+255
-23
@@ -1,8 +1,13 @@
|
|||||||
|
import base64
|
||||||
import fcntl
|
import fcntl
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import psycopg2
|
import psycopg2
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
from patroni.exceptions import PostgresConnectionException
|
||||||
|
from patroni.utils import Retry, RetryFailedError
|
||||||
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
||||||
from six.moves.socketserver import ThreadingMixIn
|
from six.moves.socketserver import ThreadingMixIn
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
@@ -10,33 +15,224 @@ from threading import Thread
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def check_auth(func):
|
||||||
|
"""Decorator function to check authorization header.
|
||||||
|
|
||||||
|
Usage example:
|
||||||
|
@check_auth
|
||||||
|
def do_PUT_foo():
|
||||||
|
pass
|
||||||
|
"""
|
||||||
|
def wrapper(handler):
|
||||||
|
if handler.check_auth_header():
|
||||||
|
return func(handler)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
class RestApiHandler(BaseHTTPRequestHandler):
|
class RestApiHandler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
def do_GET(self):
|
def send_auth_request(self, body):
|
||||||
response = self.get_postgresql_status()
|
self.send_response(401)
|
||||||
|
self.send_header('WWW-Authenticate', 'Basic realm=\"Patroni\"')
|
||||||
|
self.send_header('Content-type', 'text/html')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body.encode('utf-8'))
|
||||||
|
|
||||||
|
def finish(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
if not self.wfile.closed:
|
||||||
|
self.wfile.flush()
|
||||||
|
self.wfile.close()
|
||||||
|
except socket.error:
|
||||||
|
pass
|
||||||
|
self.rfile.close()
|
||||||
|
|
||||||
|
def check_auth_header(self):
|
||||||
|
auth_header = self.headers.get('Authorization')
|
||||||
|
status = self.server.check_auth_header(auth_header)
|
||||||
|
return not status or self.send_auth_request(status)
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self.do_GET(options=True)
|
||||||
|
|
||||||
|
def do_GET(self, options=False):
|
||||||
|
"""Default method for processing all GET requests which can not be routed to other methods"""
|
||||||
|
|
||||||
path = '/master' if self.path == '/' else self.path
|
path = '/master' if self.path == '/' else self.path
|
||||||
status_code = 200 if response['running'] and 'role' in response and response['role'] in path else 503
|
response = self.get_postgresql_status()
|
||||||
|
response.update(self.get_tags())
|
||||||
|
|
||||||
|
patroni = self.server.patroni
|
||||||
|
cluster = patroni.dcs.cluster
|
||||||
|
if cluster: # dcs available
|
||||||
|
if cluster.leader and cluster.leader.name == patroni.postgresql.name: # is_leader
|
||||||
|
status_code = 200 if 'master' in path else 503
|
||||||
|
elif 'role' not in response:
|
||||||
|
status_code = 503
|
||||||
|
elif response['role'] == 'master': # running as master but without leader lock!!!!
|
||||||
|
status_code = 503
|
||||||
|
elif response['role'] in path:
|
||||||
|
status_code = 200
|
||||||
|
else:
|
||||||
|
status_code = 503
|
||||||
|
elif 'role' in response and response['role'] in path:
|
||||||
|
status_code = 200
|
||||||
|
elif patroni.ha.restart_scheduled() and patroni.postgresql.role == 'master' and 'master' in path:
|
||||||
|
# exceptional case for master node when the postgres is being restarted via API
|
||||||
|
status_code = 200
|
||||||
|
else:
|
||||||
|
status_code = 503
|
||||||
|
|
||||||
self.send_response(status_code)
|
self.send_response(status_code)
|
||||||
|
if not options:
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(response).encode('utf-8'))
|
||||||
|
|
||||||
|
def do_GET_patroni(self):
|
||||||
|
response = self.get_postgresql_status(True)
|
||||||
|
response.update(self.get_tags())
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
self.send_header('Content-Type', 'application/json')
|
self.send_header('Content-Type', 'application/json')
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(json.dumps(response).encode('utf-8'))
|
self.wfile.write(json.dumps(response).encode('utf-8'))
|
||||||
|
|
||||||
def get_postgresql_status(self):
|
@check_auth
|
||||||
|
def do_POST_restart(self):
|
||||||
|
status_code = 503
|
||||||
|
data = b'restart failed'
|
||||||
try:
|
try:
|
||||||
row = self.server.query("""SELECT to_char(pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),
|
status, msg = self.server.patroni.ha.restart()
|
||||||
pg_is_in_recovery(),
|
status_code = 200 if status else 503
|
||||||
CASE WHEN pg_is_in_recovery()
|
data = msg.encode('utf-8')
|
||||||
THEN null
|
except:
|
||||||
ELSE pg_current_xlog_location() END,
|
logger.exception('Exception during restart')
|
||||||
pg_last_xlog_receive_location(),
|
|
||||||
pg_last_xlog_replay_location(),
|
self.send_response(status_code)
|
||||||
pg_is_in_recovery() AND pg_is_xlog_replay_paused()""")[0]
|
self.send_header('Content-Type', 'text/html')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
@check_auth
|
||||||
|
def do_POST_reinitialize(self):
|
||||||
|
ha = self.server.patroni.ha
|
||||||
|
cluster = ha.dcs.get_cluster()
|
||||||
|
if cluster.is_unlocked():
|
||||||
|
status_code = 503
|
||||||
|
data = b'Cluster has no leader, can not reinitialize'
|
||||||
|
elif cluster.leader.name == ha.state_handler.name:
|
||||||
|
status_code = 503
|
||||||
|
data = b'I am the leader, can not reinitialize'
|
||||||
|
else:
|
||||||
|
action = ha.schedule_reinitialize()
|
||||||
|
if action is not None:
|
||||||
|
status_code = 503
|
||||||
|
data = (action + ' already in progress').encode('utf-8')
|
||||||
|
else:
|
||||||
|
status_code = 200
|
||||||
|
data = b'reinitialize scheduled'
|
||||||
|
|
||||||
|
self.send_response(status_code)
|
||||||
|
self.send_header('Content-Type', 'text/html')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def poll_failover_result(self, leader, member):
|
||||||
|
for a in range(0, 15):
|
||||||
|
time.sleep(1)
|
||||||
|
try:
|
||||||
|
cluster = self.server.patroni.dcs.get_cluster()
|
||||||
|
if cluster.leader and cluster.leader.name != leader:
|
||||||
|
return 200, ('Successfully failed over to ' + cluster.leader.name).encode('utf-8')
|
||||||
|
if not cluster.failover:
|
||||||
|
return 503, b'Failover failed'
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return 503, b'Failover status unknown'
|
||||||
|
|
||||||
|
def is_failover_possible(self, cluster, leader, member):
|
||||||
|
if leader and not cluster.leader or cluster.leader.name != leader:
|
||||||
|
return b'leader name does not match'
|
||||||
|
if member:
|
||||||
|
members = [m for m in cluster.members if m.name == member]
|
||||||
|
if not members:
|
||||||
|
return b'member does not exists'
|
||||||
|
else:
|
||||||
|
members = [m for m in cluster.members if m.name != cluster.leader.name and m.api_url]
|
||||||
|
if not members:
|
||||||
|
return b'failover is not possible: cluster does not have members except leader'
|
||||||
|
for member, reachable, in_recovery, xlog_location, tags in self.server.patroni.ha.fetch_nodes_statuses(members):
|
||||||
|
if reachable and not tags.get('nofailover', False):
|
||||||
|
return None
|
||||||
|
return b'failover is not possible: no good candidates have been found'
|
||||||
|
|
||||||
|
@check_auth
|
||||||
|
def do_POST_failover(self):
|
||||||
|
content_length = int(self.headers.get('content-length', 0))
|
||||||
|
request = json.loads(self.rfile.read(content_length).decode('utf-8'))
|
||||||
|
leader = request.get('leader', None)
|
||||||
|
member = request.get('member', None)
|
||||||
|
cluster = self.server.patroni.ha.dcs.get_cluster()
|
||||||
|
status_code = 503
|
||||||
|
data = self.is_failover_possible(cluster, leader, member)
|
||||||
|
if not data:
|
||||||
|
if not self.server.patroni.dcs.manual_failover(leader, member):
|
||||||
|
data = b'failed to write failover key into DCS'
|
||||||
|
else:
|
||||||
|
self.server.patroni.dcs.event.set()
|
||||||
|
status_code, data = self.poll_failover_result(cluster.leader and cluster.leader.name, member)
|
||||||
|
|
||||||
|
self.send_response(status_code)
|
||||||
|
self.send_header('Content-Type', 'text/html')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def parse_request(self):
|
||||||
|
"""Override parse_request method to enrich basic functionality of `BaseHTTPRequestHandler` class
|
||||||
|
|
||||||
|
Original class can only invoke do_GET, do_POST, do_PUT, etc method implementations if they are defined.
|
||||||
|
But we would like to have at least some simple routing mechanism, i.e.:
|
||||||
|
GET /uri1/part2 request should invoke `do_GET_uri1()`
|
||||||
|
POST /other should invoke `do_POST_other()`
|
||||||
|
|
||||||
|
If the `do_<REQUEST_METHOD>_<first_part_url>` method does not exists we'll fallback to original behavior."""
|
||||||
|
|
||||||
|
ret = BaseHTTPRequestHandler.parse_request(self)
|
||||||
|
if ret:
|
||||||
|
mname = self.path.lstrip('/').split('/')[0]
|
||||||
|
mname = self.command + ('_' + mname if mname else '')
|
||||||
|
if hasattr(self, 'do_' + mname):
|
||||||
|
self.command = mname
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def handle_one_request(self):
|
||||||
|
try:
|
||||||
|
BaseHTTPRequestHandler.handle_one_request(self)
|
||||||
|
except socket.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def query(self, sql, *params, **kwargs):
|
||||||
|
if not kwargs.get('retry', False):
|
||||||
|
return self.server.query(sql, *params)
|
||||||
|
retry = Retry(delay=1, retry_exceptions=PostgresConnectionException)
|
||||||
|
return retry(self.server.query, sql, *params)
|
||||||
|
|
||||||
|
def get_postgresql_status(self, retry=False):
|
||||||
|
try:
|
||||||
|
row = self.query("""SELECT to_char(pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),
|
||||||
|
pg_is_in_recovery(),
|
||||||
|
CASE WHEN pg_is_in_recovery()
|
||||||
|
THEN 0
|
||||||
|
ELSE pg_xlog_location_diff(pg_current_xlog_location(), '0/0')::bigint
|
||||||
|
END,
|
||||||
|
pg_xlog_location_diff(pg_last_xlog_receive_location(), '0/0')::bigint,
|
||||||
|
pg_xlog_location_diff(pg_last_xlog_replay_location(), '0/0')::bigint,
|
||||||
|
pg_is_in_recovery() AND pg_is_xlog_replay_paused()""", retry=retry)[0]
|
||||||
return {
|
return {
|
||||||
'running': True,
|
'state': self.server.patroni.postgresql.state,
|
||||||
'postmaster_start_time': row[0],
|
'postmaster_start_time': row[0],
|
||||||
'role': 'slave' if row[1] else 'master',
|
'role': 'replica' if row[1] else 'master',
|
||||||
'xlog': ({
|
'xlog': ({
|
||||||
'received_location': row[3],
|
'received_location': row[3],
|
||||||
'replayed_location': row[4],
|
'replayed_location': row[4],
|
||||||
@@ -44,30 +240,66 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
'location': row[2]
|
'location': row[2]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
except (psycopg2.OperationalError, psycopg2.InterfaceError):
|
except (psycopg2.Error, RetryFailedError, PostgresConnectionException):
|
||||||
logger.exception('get_postgresql_status')
|
state = self.server.patroni.postgresql.state
|
||||||
return {'running': self.server.patroni.postgresql.is_running()}
|
if state == 'running':
|
||||||
|
logger.exception('get_postgresql_status')
|
||||||
|
state = 'unknown'
|
||||||
|
return {'state': state}
|
||||||
|
|
||||||
|
def get_tags(self):
|
||||||
|
return {'tags': self.server.patroni.tags}
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
logger.debug("API thread: " + format % args)
|
||||||
|
|
||||||
|
|
||||||
class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||||
|
|
||||||
def __init__(self, patroni, config):
|
def __init__(self, patroni, config):
|
||||||
self.connection_string = 'http://{}/patroni'.format(config.get('connect_address', None) or config['listen'])
|
self._auth_key = base64.b64encode(config['auth'].encode('utf-8')).decode('utf-8') if 'auth' in config else None
|
||||||
host, port = config['listen'].split(':')
|
host, port = config['listen'].split(':')
|
||||||
HTTPServer.__init__(self, (host, int(port)), RestApiHandler)
|
HTTPServer.__init__(self, (host, int(port)), RestApiHandler)
|
||||||
Thread.__init__(self, target=self.serve_forever)
|
Thread.__init__(self, target=self.serve_forever)
|
||||||
self._set_fd_cloexec(self.socket)
|
self._set_fd_cloexec(self.socket)
|
||||||
|
|
||||||
|
protocol = 'http'
|
||||||
|
|
||||||
|
# wrap socket with ssl if 'certfile' is defined in a config.yaml
|
||||||
|
# Sometime it's also needed to pass reference to a 'keyfile'.
|
||||||
|
options = {option: config[option] for option in ['certfile', 'keyfile'] if option in config}
|
||||||
|
if options.get('certfile', None):
|
||||||
|
import ssl
|
||||||
|
self.socket = ssl.wrap_socket(self.socket, server_side=True, **options)
|
||||||
|
protocol = 'https'
|
||||||
|
|
||||||
|
self.connection_string = '{}://{}/patroni'.format(protocol, config.get('connect_address', config['listen']))
|
||||||
|
|
||||||
self.patroni = patroni
|
self.patroni = patroni
|
||||||
self.daemon = True
|
self.daemon = True
|
||||||
|
|
||||||
def query(self, sql, *params):
|
def query(self, sql, *params):
|
||||||
cursor = self.patroni.postgresql.connection().cursor()
|
cursor = None
|
||||||
cursor.execute(sql, params)
|
try:
|
||||||
ret = [r for r in cursor]
|
with self.patroni.postgresql.connection().cursor() as cursor:
|
||||||
cursor.close()
|
cursor.execute(sql, params)
|
||||||
return ret
|
return [r for r in cursor]
|
||||||
|
except psycopg2.Error as e:
|
||||||
|
if cursor and cursor.connection.closed == 0:
|
||||||
|
raise e
|
||||||
|
raise PostgresConnectionException('connection problems')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _set_fd_cloexec(fd):
|
def _set_fd_cloexec(fd):
|
||||||
flags = fcntl.fcntl(fd, fcntl.F_GETFD)
|
flags = fcntl.fcntl(fd, fcntl.F_GETFD)
|
||||||
fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC)
|
fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC)
|
||||||
|
|
||||||
|
def check_basic_auth_key(self, key):
|
||||||
|
return self._auth_key == key
|
||||||
|
|
||||||
|
def check_auth_header(self, auth_header):
|
||||||
|
if self._auth_key:
|
||||||
|
if auth_header is None:
|
||||||
|
return 'no auth header received'
|
||||||
|
if not auth_header.startswith('Basic ') or not self.check_basic_auth_key(auth_header[6:]):
|
||||||
|
return 'not authenticated'
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import logging
|
||||||
|
from threading import Lock, Thread
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncExecutor:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
Lock.__init__(self)
|
||||||
|
self._busy = False
|
||||||
|
self._thread_lock = Lock()
|
||||||
|
self._scheduled_action = None
|
||||||
|
self._scheduled_action_lock = Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def busy(self):
|
||||||
|
return self._busy
|
||||||
|
|
||||||
|
def schedule(self, action, immediately=False):
|
||||||
|
with self._scheduled_action_lock:
|
||||||
|
if self._scheduled_action is not None:
|
||||||
|
return self._scheduled_action
|
||||||
|
self._scheduled_action = action
|
||||||
|
self._busy = immediately
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scheduled_action(self):
|
||||||
|
with self._scheduled_action_lock:
|
||||||
|
return self._scheduled_action
|
||||||
|
|
||||||
|
def reset_scheduled_action(self):
|
||||||
|
with self._scheduled_action_lock:
|
||||||
|
self._scheduled_action = None
|
||||||
|
|
||||||
|
def run(self, func, args=()):
|
||||||
|
try:
|
||||||
|
return func(*args) if args else func()
|
||||||
|
except:
|
||||||
|
logger.exception('Exception during execution of long running task %s', self.scheduled_action)
|
||||||
|
finally:
|
||||||
|
with self:
|
||||||
|
self._busy = False
|
||||||
|
self.reset_scheduled_action()
|
||||||
|
|
||||||
|
def run_async(self, func, args=()):
|
||||||
|
self._busy = True
|
||||||
|
Thread(target=self.run, args=(func, args)).start()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self._thread_lock.acquire()
|
||||||
|
|
||||||
|
def __exit__(self, type, value, traceback):
|
||||||
|
self._thread_lock.release()
|
||||||
+616
@@ -0,0 +1,616 @@
|
|||||||
|
'''
|
||||||
|
Patroni Control
|
||||||
|
'''
|
||||||
|
|
||||||
|
import click
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import psycopg2
|
||||||
|
import random
|
||||||
|
import requests
|
||||||
|
import datetime
|
||||||
|
from prettytable import PrettyTable
|
||||||
|
from six.moves.urllib_parse import urlparse
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .etcd import Etcd
|
||||||
|
from .exceptions import PatroniCtlException
|
||||||
|
from .postgresql import parseurl
|
||||||
|
|
||||||
|
CONFIG_DIR_PATH = click.get_app_dir('patroni')
|
||||||
|
CONFIG_FILE_PATH = os.path.join(CONFIG_DIR_PATH, 'patronictl.yaml')
|
||||||
|
LOGLEVEL = 'WARNING'
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dcs(dcs):
|
||||||
|
"""
|
||||||
|
Break up the provided dcs string
|
||||||
|
>>> parse_dcs('localhost') == {'scheme': 'etcd', 'hostname': 'localhost', 'port': 4001}
|
||||||
|
True
|
||||||
|
>>> parse_dcs('localhost:8500') == {'scheme': 'consul', 'hostname': 'localhost', 'port': 8500}
|
||||||
|
True
|
||||||
|
>>> parse_dcs('zookeeper://localhost') == {'scheme': 'zookeeper', 'hostname': 'localhost', 'port': 2181}
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not dcs:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
parsed = urlparse(dcs)
|
||||||
|
scheme = parsed.scheme
|
||||||
|
if scheme == '' and parsed.netloc == '':
|
||||||
|
parsed = urlparse('//' + dcs)
|
||||||
|
|
||||||
|
if scheme == '':
|
||||||
|
default_schemes = {'2181': 'zookeeper', '8500': 'consul'}
|
||||||
|
scheme = default_schemes.get(str(parsed.port), 'etcd')
|
||||||
|
|
||||||
|
port = parsed.port
|
||||||
|
if port is None:
|
||||||
|
default_ports = {'consul': 8500, 'zookeeper': 2181}
|
||||||
|
port = default_ports.get(str(scheme), 4001)
|
||||||
|
|
||||||
|
return {'scheme': str(scheme), 'hostname': str(parsed.hostname), 'port': int(port)}
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path, dcs):
|
||||||
|
logging.debug('Loading configuration from file {}'.format(path))
|
||||||
|
config = dict()
|
||||||
|
try:
|
||||||
|
with open(path, 'rb') as fd:
|
||||||
|
config = yaml.safe_load(fd)
|
||||||
|
except:
|
||||||
|
logging.exception('Could not load configuration file')
|
||||||
|
|
||||||
|
if dcs:
|
||||||
|
config['dcs'] = parse_dcs(dcs)
|
||||||
|
else:
|
||||||
|
config['dcs'] = parse_dcs(config.get('dcs_api'))
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def store_config(config, path):
|
||||||
|
dir_path = os.path.dirname(path)
|
||||||
|
if dir_path:
|
||||||
|
if not os.path.isdir(dir_path):
|
||||||
|
os.makedirs(dir_path)
|
||||||
|
with open(path, 'w') as fd:
|
||||||
|
yaml.dump(config, fd)
|
||||||
|
|
||||||
|
|
||||||
|
option_config_file = click.option('--config-file', '-c', help='Configuration file', default=CONFIG_FILE_PATH)
|
||||||
|
option_format = click.option('--format', '-f', help='Output format (pretty, json)', default='pretty')
|
||||||
|
option_dcs = click.option('--dcs', '-d', help='Use this DCS', envvar='DCS')
|
||||||
|
option_watchrefresh = click.option('-w', '--watch', type=float, help='Auto update the screen every X seconds')
|
||||||
|
option_watch = click.option('-W', is_flag=True, help='Auto update the screen every 2 seconds')
|
||||||
|
option_force = click.option('--force', is_flag=True, help='Do not ask for confirmation at any point')
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
@click.pass_context
|
||||||
|
def ctl(ctx):
|
||||||
|
global LOGLEVEL
|
||||||
|
LOGLEVEL = os.environ.get('LOGLEVEL', LOGLEVEL)
|
||||||
|
|
||||||
|
logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=LOGLEVEL)
|
||||||
|
|
||||||
|
|
||||||
|
def get_dcs(config, scope):
|
||||||
|
scheme, hostname, port = map(config.get('dcs', {}).get, ('scheme', 'hostname', 'port'))
|
||||||
|
|
||||||
|
if scheme == 'etcd':
|
||||||
|
return Etcd(name=scope, config={'scope': scope, 'host': '{}:{}'.format(hostname, port)})
|
||||||
|
|
||||||
|
raise PatroniCtlException('Can not find suitable configuration of distributed configuration store')
|
||||||
|
|
||||||
|
|
||||||
|
def post_patroni(member, endpoint, content, headers={'Content-Type': 'application/json'}):
|
||||||
|
url = urlparse(member.api_url)
|
||||||
|
logging.debug(url)
|
||||||
|
return requests.post('{}://{}/{}'.format(url.scheme, url.netloc, endpoint), headers=headers,
|
||||||
|
data=json.dumps(content), timeout=60)
|
||||||
|
|
||||||
|
|
||||||
|
def print_output(columns, rows=[], alignment=None, format='pretty', header=True, delimiter='\t'):
|
||||||
|
if format == 'pretty':
|
||||||
|
t = PrettyTable(columns)
|
||||||
|
for k, v in (alignment or {}).items():
|
||||||
|
t.align[k] = v
|
||||||
|
for r in rows:
|
||||||
|
t.add_row(r)
|
||||||
|
click.echo(t)
|
||||||
|
return
|
||||||
|
|
||||||
|
if format == 'json':
|
||||||
|
elements = list()
|
||||||
|
for r in rows:
|
||||||
|
elements.append(dict(zip(columns, r)))
|
||||||
|
|
||||||
|
click.echo(json.dumps(elements))
|
||||||
|
|
||||||
|
if format == 'tsv':
|
||||||
|
if columns is not None and header:
|
||||||
|
click.echo(delimiter.join(columns) + '\n')
|
||||||
|
|
||||||
|
for r in rows or []:
|
||||||
|
c = [str(c) for c in r]
|
||||||
|
click.echo(delimiter.join(c))
|
||||||
|
|
||||||
|
|
||||||
|
def watching(w, watch, max_count=None, clear=True):
|
||||||
|
"""
|
||||||
|
>>> len(list(watching(True, 1, 0)))
|
||||||
|
1
|
||||||
|
>>> len(list(watching(True, 1, 1)))
|
||||||
|
2
|
||||||
|
>>> len(list(watching(True, None, 0)))
|
||||||
|
1
|
||||||
|
"""
|
||||||
|
|
||||||
|
if w and not watch:
|
||||||
|
watch = 2
|
||||||
|
if watch and clear:
|
||||||
|
click.clear()
|
||||||
|
yield 0
|
||||||
|
|
||||||
|
if max_count is not None and max_count < 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
counter = 1
|
||||||
|
while watch and counter <= (max_count or counter):
|
||||||
|
time.sleep(watch)
|
||||||
|
counter += 1
|
||||||
|
if clear:
|
||||||
|
click.clear()
|
||||||
|
yield 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_connect_parameters(conn_url, connect_parameters={}):
|
||||||
|
params = connect_parameters.copy()
|
||||||
|
parsed = parseurl(conn_url)
|
||||||
|
params['host'] = parsed['host']
|
||||||
|
params['port'] = parsed['port']
|
||||||
|
params['fallback_application_name'] = 'Patroni ctl'
|
||||||
|
params['connect_timeout'] = '5'
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_members(cluster, role='master'):
|
||||||
|
if role == 'master':
|
||||||
|
if cluster.leader is not None:
|
||||||
|
yield cluster.leader
|
||||||
|
return
|
||||||
|
|
||||||
|
leader_name = (cluster.leader.member.name if cluster.leader else None)
|
||||||
|
for m in cluster.members:
|
||||||
|
if role == 'any' or role == 'replica' and m.name != leader_name:
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
|
def get_any_member(cluster, role='master', member=None):
|
||||||
|
members = get_all_members(cluster=cluster, role=role)
|
||||||
|
for m in members:
|
||||||
|
if member is None or m.name == member:
|
||||||
|
return m
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_cursor(cluster, role='master', member=None, connect_parameters={}):
|
||||||
|
member = get_any_member(cluster=cluster, role=role, member=member)
|
||||||
|
if member is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
params = build_connect_parameters(member.conn_url, connect_parameters=connect_parameters)
|
||||||
|
|
||||||
|
conn = psycopg2.connect(**params)
|
||||||
|
conn.autocommit = True
|
||||||
|
cursor = conn.cursor()
|
||||||
|
if role == 'any':
|
||||||
|
return cursor
|
||||||
|
|
||||||
|
cursor.execute('SELECT pg_is_in_recovery()')
|
||||||
|
in_recovery = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
if in_recovery and role == 'replica' or not in_recovery and role == 'master':
|
||||||
|
return cursor
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@ctl.command('dsn', help='Generate a dsn for the provided member, defaults to a dsn of the master')
|
||||||
|
@click.option('--role', '-r', help='Give a dsn of any member with this role', type=click.Choice(['master', 'replica',
|
||||||
|
'any']), default=None)
|
||||||
|
@click.option('--member', '-m', help='Generate a dsn for this member', type=str)
|
||||||
|
@option_dcs
|
||||||
|
@option_config_file
|
||||||
|
@click.argument('cluster_name')
|
||||||
|
def dsn(cluster_name, config_file, dcs, role, member):
|
||||||
|
if role is not None and member is not None:
|
||||||
|
raise PatroniCtlException('--role and --member are mutually exclusive options')
|
||||||
|
if member is None and role is None:
|
||||||
|
role = 'master'
|
||||||
|
|
||||||
|
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
|
||||||
|
m = get_any_member(cluster=cluster, role=role, member=member)
|
||||||
|
if m is None:
|
||||||
|
raise PatroniCtlException('Can not find a suitable member')
|
||||||
|
|
||||||
|
params = build_connect_parameters(m.conn_url)
|
||||||
|
click.echo('host={} port={}'.format(params['host'], params['port']))
|
||||||
|
|
||||||
|
|
||||||
|
@ctl.command('query', help='Query a Patroni PostgreSQL member')
|
||||||
|
@click.argument('cluster_name')
|
||||||
|
@option_config_file
|
||||||
|
@option_format
|
||||||
|
@click.option('--format', help='Output format (pretty, json)', default='tsv')
|
||||||
|
@click.option('--file', '-f', help='Execute the SQL commands from this file', type=click.File('rb'))
|
||||||
|
@option_dcs
|
||||||
|
@option_watch
|
||||||
|
@option_watchrefresh
|
||||||
|
@click.option('--role', '-r', help='The role of the query', type=click.Choice(['master', 'replica', 'any']),
|
||||||
|
default=None)
|
||||||
|
@click.option('--member', '-m', help='Query a specific member', type=str)
|
||||||
|
@click.option('--delimiter', help='The column delimiter', default='\t')
|
||||||
|
@click.option('--command', '-c', help='The SQL commands to execute')
|
||||||
|
def query(
|
||||||
|
cluster_name,
|
||||||
|
config_file,
|
||||||
|
dcs,
|
||||||
|
role,
|
||||||
|
member,
|
||||||
|
w,
|
||||||
|
watch,
|
||||||
|
delimiter,
|
||||||
|
command,
|
||||||
|
file,
|
||||||
|
format='tsv',
|
||||||
|
):
|
||||||
|
if role is not None and member is not None:
|
||||||
|
raise PatroniCtlException('--role and --member are mutually exclusive options')
|
||||||
|
if member is None and role is None:
|
||||||
|
role = 'master'
|
||||||
|
|
||||||
|
if file is not None and command is not None:
|
||||||
|
raise PatroniCtlException('--file and --command are mutually exclusive options')
|
||||||
|
|
||||||
|
if file is not None:
|
||||||
|
command = file.read()
|
||||||
|
|
||||||
|
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
|
||||||
|
|
||||||
|
cursor = None
|
||||||
|
for _ in watching(w, watch, clear=False):
|
||||||
|
|
||||||
|
output, cursor = query_member(cluster=cluster, cursor=cursor, member=member, role=role, command=command)
|
||||||
|
print_output(None, output, format=format, delimiter=delimiter)
|
||||||
|
|
||||||
|
if cursor is None:
|
||||||
|
cluster = dcs.get_cluster()
|
||||||
|
|
||||||
|
|
||||||
|
def query_member(cluster, cursor, member, role, command):
|
||||||
|
try:
|
||||||
|
if cursor is None:
|
||||||
|
cursor = get_cursor(cluster, role=role, member=member)
|
||||||
|
|
||||||
|
if cursor is None:
|
||||||
|
if role is None:
|
||||||
|
message = 'No connection to member {} is available'.format(member)
|
||||||
|
else:
|
||||||
|
message = 'No connection to role={} is available'.format(role)
|
||||||
|
logging.debug(message)
|
||||||
|
return [[timestamp(0), message]], None
|
||||||
|
|
||||||
|
cursor.execute('SELECT pg_is_in_recovery()')
|
||||||
|
in_recovery = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
if in_recovery and role == 'master' or not in_recovery and role == 'replica':
|
||||||
|
cursor.connection.close()
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
cursor.execute(command)
|
||||||
|
return cursor.fetchall(), cursor
|
||||||
|
except (psycopg2.OperationalError, psycopg2.DatabaseError) as oe:
|
||||||
|
logging.debug(oe)
|
||||||
|
if cursor is not None and not cursor.connection.closed:
|
||||||
|
cursor.connection.close()
|
||||||
|
message = oe.pgcode or oe.pgerror or str(oe)
|
||||||
|
message = message.replace('\n', ' ')
|
||||||
|
return [[timestamp(0), 'ERROR, SQLSTATE: {}'.format(message)]], None
|
||||||
|
|
||||||
|
|
||||||
|
@ctl.command('remove', help='Remove cluster from DCS')
|
||||||
|
@click.argument('cluster_name')
|
||||||
|
@option_config_file
|
||||||
|
@option_format
|
||||||
|
@option_dcs
|
||||||
|
def remove(config_file, cluster_name, format, dcs):
|
||||||
|
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
|
||||||
|
|
||||||
|
if not isinstance(dcs, Etcd):
|
||||||
|
raise PatroniCtlException('We have not implemented this for DCS of type {}'.format(type(dcs)))
|
||||||
|
|
||||||
|
output_members(cluster, format=format)
|
||||||
|
|
||||||
|
confirm = click.prompt('Please confirm the cluster name to remove', type=str)
|
||||||
|
if confirm != cluster_name:
|
||||||
|
raise PatroniCtlException('Cluster names specified do not match')
|
||||||
|
|
||||||
|
message = 'Yes I am aware'
|
||||||
|
confirm = \
|
||||||
|
click.prompt('You are about to remove all information in DCS for {}, please type: "{}"'.format(cluster_name,
|
||||||
|
message), type=str)
|
||||||
|
if message != confirm:
|
||||||
|
raise PatroniCtlException('You did not exactly type "{}"'.format(message))
|
||||||
|
|
||||||
|
if cluster.leader:
|
||||||
|
confirm = click.prompt('This cluster currently is healthy. Please specify the master name to continue')
|
||||||
|
if confirm != cluster.leader.name:
|
||||||
|
raise PatroniCtlException('You did not specify the current master of the cluster')
|
||||||
|
|
||||||
|
dcs.client.delete(dcs._base_path, recursive=True)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_leader(dcs, timeout=30):
|
||||||
|
t_stop = time.time() + timeout
|
||||||
|
timeout /= 2
|
||||||
|
|
||||||
|
while time.time() < t_stop:
|
||||||
|
dcs.watch(timeout)
|
||||||
|
cluster = dcs.get_cluster()
|
||||||
|
|
||||||
|
if cluster.leader:
|
||||||
|
return cluster
|
||||||
|
|
||||||
|
raise PatroniCtlException('Timeout occured')
|
||||||
|
|
||||||
|
|
||||||
|
def empty_post_to_members(cluster, member_names, force, endpoint):
|
||||||
|
candidates = dict()
|
||||||
|
for m in cluster.members:
|
||||||
|
candidates[m.name] = m
|
||||||
|
|
||||||
|
if len(member_names) == 0:
|
||||||
|
member_names = [click.prompt('Which member do you want to {} [{}]?'.format(endpoint,
|
||||||
|
', '.join(candidates.keys())), type=str, default='')]
|
||||||
|
|
||||||
|
for mn in member_names:
|
||||||
|
if mn not in candidates.keys():
|
||||||
|
raise PatroniCtlException('{} is not a member of cluster'.format(mn))
|
||||||
|
|
||||||
|
if not force:
|
||||||
|
confirm = click.confirm('Are you sure you want to {} members {}?'.format(endpoint, ', '.join(member_names)))
|
||||||
|
if not confirm:
|
||||||
|
raise PatroniCtlException('Aborted {}'.format(endpoint))
|
||||||
|
|
||||||
|
for mn in member_names:
|
||||||
|
r = post_patroni(candidates[mn], endpoint, '')
|
||||||
|
if r.status_code != 200:
|
||||||
|
click.echo('{} failed for member {}, status code={}, ({})'.format(endpoint, mn, r.status_code, r.text))
|
||||||
|
else:
|
||||||
|
click.echo('Succesful {} on member {}'.format(endpoint, mn))
|
||||||
|
|
||||||
|
|
||||||
|
def ctl_load_config(cluster_name, config_file, dcs):
|
||||||
|
config = load_config(config_file, dcs)
|
||||||
|
dcs = get_dcs(config, cluster_name)
|
||||||
|
cluster = dcs.get_cluster()
|
||||||
|
|
||||||
|
return config, dcs, cluster
|
||||||
|
|
||||||
|
|
||||||
|
@ctl.command('restart', help='Restart cluster member')
|
||||||
|
@click.argument('cluster_name')
|
||||||
|
@click.argument('member_names', nargs=-1)
|
||||||
|
@click.option('--role', '-r', help='Restart only members with this role', default='any',
|
||||||
|
type=click.Choice(['master', 'replica', 'any']))
|
||||||
|
@click.option('--any', help='Restart a single member only', is_flag=True)
|
||||||
|
@option_config_file
|
||||||
|
@option_force
|
||||||
|
@option_dcs
|
||||||
|
def restart(cluster_name, member_names, config_file, dcs, force, role, any):
|
||||||
|
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
|
||||||
|
|
||||||
|
role_names = [m.name for m in get_all_members(cluster=cluster, role=role)]
|
||||||
|
|
||||||
|
if len(member_names) > 0:
|
||||||
|
member_names = list(set(member_names) & set(role_names))
|
||||||
|
else:
|
||||||
|
member_names = role_names
|
||||||
|
|
||||||
|
if any:
|
||||||
|
random.shuffle(member_names)
|
||||||
|
member_names = member_names[:1]
|
||||||
|
|
||||||
|
output_members(cluster)
|
||||||
|
empty_post_to_members(cluster, member_names, force, 'restart')
|
||||||
|
|
||||||
|
|
||||||
|
@ctl.command('reinit', help='Reinitialize cluster member')
|
||||||
|
@click.argument('cluster_name')
|
||||||
|
@click.argument('member_names', nargs=-1)
|
||||||
|
@option_config_file
|
||||||
|
@option_force
|
||||||
|
@option_dcs
|
||||||
|
def reinit(cluster_name, member_names, config_file, dcs, force):
|
||||||
|
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
|
||||||
|
empty_post_to_members(cluster, member_names, force, 'reinitialize')
|
||||||
|
|
||||||
|
|
||||||
|
@ctl.command('failover', help='Failover to a replica')
|
||||||
|
@click.argument('cluster_name')
|
||||||
|
@click.option('--master', help='The name of the current master', default=None)
|
||||||
|
@click.option('--candidate', help='The name of the candidate', default=None)
|
||||||
|
@click.option('--force', is_flag=True)
|
||||||
|
@option_config_file
|
||||||
|
@option_dcs
|
||||||
|
def failover(config_file, cluster_name, master, candidate, force, dcs):
|
||||||
|
"""
|
||||||
|
We want to trigger a failover for the specified cluster name.
|
||||||
|
|
||||||
|
We verify that the cluster name, master name and candidate name are correct.
|
||||||
|
If so, we trigger a failover and keep the client up to date.
|
||||||
|
"""
|
||||||
|
|
||||||
|
config, dcs, cluster = ctl_load_config(cluster_name, config_file, dcs)
|
||||||
|
|
||||||
|
if cluster.leader is None:
|
||||||
|
raise PatroniCtlException('This cluster has no master')
|
||||||
|
|
||||||
|
if master is None:
|
||||||
|
if force:
|
||||||
|
master = cluster.leader.member.name
|
||||||
|
else:
|
||||||
|
master = click.prompt('Master', type=str, default=cluster.leader.member.name)
|
||||||
|
|
||||||
|
if cluster.leader.member.name != master:
|
||||||
|
raise PatroniCtlException('Member {} is not the leader of cluster {}'.format(master, cluster_name))
|
||||||
|
|
||||||
|
candidate_names = [str(m.name) for m in cluster.members if m.name != master]
|
||||||
|
# We sort the names for consistent output to the client
|
||||||
|
candidate_names.sort()
|
||||||
|
|
||||||
|
if len(candidate_names) == 0:
|
||||||
|
raise PatroniCtlException('No candidates found to failover to')
|
||||||
|
|
||||||
|
if candidate is None and not force:
|
||||||
|
candidate = click.prompt('Candidate ' + str(candidate_names), type=str, default='')
|
||||||
|
|
||||||
|
if candidate == master:
|
||||||
|
raise PatroniCtlException('Failover target and source are the same.')
|
||||||
|
|
||||||
|
if candidate and candidate not in candidate_names:
|
||||||
|
raise PatroniCtlException('Member {} does not exist in cluster {}'.format(candidate, cluster_name))
|
||||||
|
|
||||||
|
# By now we have established that the leader exists and the candidate exists
|
||||||
|
click.echo('Current cluster topology')
|
||||||
|
output_members(dcs.get_cluster(), name=cluster_name)
|
||||||
|
|
||||||
|
if not force:
|
||||||
|
a = \
|
||||||
|
click.confirm('Are you sure you want to failover cluster {}, demoting current master {}?'.format(
|
||||||
|
cluster_name, master))
|
||||||
|
if not a:
|
||||||
|
raise PatroniCtlException('Aborting failover')
|
||||||
|
|
||||||
|
failover_value = '{}:{}'.format(master, candidate or '')
|
||||||
|
|
||||||
|
t_started = time.time()
|
||||||
|
r = None
|
||||||
|
try:
|
||||||
|
r = post_patroni(cluster.leader.member, 'failover', {'leader': master, 'member': candidate or ''})
|
||||||
|
if r.status_code == 200:
|
||||||
|
logging.debug(r)
|
||||||
|
logging.debug(r.text)
|
||||||
|
cluster = dcs.get_cluster()
|
||||||
|
click.echo(timestamp() + ' Failing over to new leader: {}'.format(cluster.leader.member.name))
|
||||||
|
else:
|
||||||
|
click.echo('Failover failed, details: {}, {}'.format(r.status_code, r.text))
|
||||||
|
return
|
||||||
|
except:
|
||||||
|
logging.exception(r)
|
||||||
|
logging.warning('Failing over to DCS')
|
||||||
|
click.echo(timestamp() + ' Could not failover using Patroni api, falling back to DCS')
|
||||||
|
dcs.set_failover_value(failover_value)
|
||||||
|
click.echo(timestamp() + ' Initialized failover from master {}'.format(master))
|
||||||
|
# The failover process should within a minute update the failover key, we will keep watching it until it changes
|
||||||
|
# or we timeout
|
||||||
|
cluster = wait_for_leader(dcs, timeout=60)
|
||||||
|
if cluster.leader.member.name == master:
|
||||||
|
click.echo('Failover failed, master did not change after {:0.1f} seconds'.format(time.time() - t_started))
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(timestamp() + ' Failover completed in {:0.1f} seconds, new leader is {}'.format(time.time() - t_started,
|
||||||
|
str(cluster.leader.member.name)))
|
||||||
|
output_members(cluster, name=cluster_name)
|
||||||
|
|
||||||
|
|
||||||
|
def output_members(cluster, name=None, format='pretty'):
|
||||||
|
rows = []
|
||||||
|
logging.debug(cluster)
|
||||||
|
leader_name = None
|
||||||
|
if cluster.leader:
|
||||||
|
leader_name = cluster.leader.member.name
|
||||||
|
|
||||||
|
xlog_location_cluster = cluster.last_leader_operation or 0
|
||||||
|
|
||||||
|
# Mainly for consistent pretty printing and watching we sort the output
|
||||||
|
cluster.members.sort(key=lambda x: x.name)
|
||||||
|
for m in cluster.members:
|
||||||
|
logging.debug(m)
|
||||||
|
|
||||||
|
leader = ''
|
||||||
|
if m.name == leader_name:
|
||||||
|
leader = '*'
|
||||||
|
|
||||||
|
host = build_connect_parameters(m.conn_url)['host']
|
||||||
|
|
||||||
|
xlog_location = m.data.get('xlog_location')
|
||||||
|
if xlog_location is None or (xlog_location_cluster < xlog_location):
|
||||||
|
lag = ''
|
||||||
|
else:
|
||||||
|
lag = round((xlog_location_cluster - xlog_location)/1024/1024)
|
||||||
|
|
||||||
|
rows.append([
|
||||||
|
name,
|
||||||
|
m.name,
|
||||||
|
host,
|
||||||
|
leader,
|
||||||
|
m.data.get('state', ''),
|
||||||
|
lag
|
||||||
|
])
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
'Cluster',
|
||||||
|
'Member',
|
||||||
|
'Host',
|
||||||
|
'Leader',
|
||||||
|
'State',
|
||||||
|
'Lag in MB',
|
||||||
|
]
|
||||||
|
alignment = {'Cluster': 'l', 'Member': 'l', 'Host': 'l', 'Lag in MB': 'r'}
|
||||||
|
|
||||||
|
print_output(columns, rows, alignment, format)
|
||||||
|
|
||||||
|
|
||||||
|
@ctl.command('list', help='List the Patroni members for a given Patroni')
|
||||||
|
@click.argument('cluster_names', nargs=-1)
|
||||||
|
@option_config_file
|
||||||
|
@option_format
|
||||||
|
@option_watch
|
||||||
|
@option_watchrefresh
|
||||||
|
@option_dcs
|
||||||
|
def members(config_file, cluster_names, format, watch, w, dcs):
|
||||||
|
if len(cluster_names) == 0:
|
||||||
|
logging.warning('Listing members: No cluster names were provided')
|
||||||
|
return
|
||||||
|
|
||||||
|
config = load_config(config_file, dcs)
|
||||||
|
for cn in cluster_names:
|
||||||
|
dcs = get_dcs(config, cn)
|
||||||
|
|
||||||
|
for _ in watching(w, watch):
|
||||||
|
output_members(dcs.get_cluster(), name=cn, format=format)
|
||||||
|
|
||||||
|
|
||||||
|
def timestamp(precision=6):
|
||||||
|
return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:precision - 7]
|
||||||
|
|
||||||
|
|
||||||
|
@ctl.command('configure', help='Create configuration file')
|
||||||
|
@click.option('--config-file', '-c', help='Configuration file', prompt='Configuration file', default=CONFIG_FILE_PATH)
|
||||||
|
@click.option('--dcs', '-d', help='The DCS connect url', prompt='DCS connect url', default='etcd://localhost:4001')
|
||||||
|
@click.option('--namespace', '-n', help='The namespace', prompt='Namespace', default='/service/')
|
||||||
|
def configure(config_file, dcs, namespace):
|
||||||
|
config = dict()
|
||||||
|
config['dcs_api'] = str(dcs)
|
||||||
|
config['namespace'] = str(namespace)
|
||||||
|
store_config(config, config_file)
|
||||||
+145
-27
@@ -1,9 +1,10 @@
|
|||||||
import abc
|
import abc
|
||||||
|
import json
|
||||||
|
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from patroni.exceptions import DCSError
|
from patroni.exceptions import DCSError
|
||||||
from patroni.utils import calculate_ttl, sleep
|
|
||||||
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
|
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
|
||||||
|
from threading import Event, Lock
|
||||||
|
|
||||||
|
|
||||||
def parse_connection_string(value):
|
def parse_connection_string(value):
|
||||||
@@ -23,28 +24,56 @@ def parse_connection_string(value):
|
|||||||
return conn_url, api_url
|
return conn_url, api_url
|
||||||
|
|
||||||
|
|
||||||
class Member(namedtuple('Member', 'index,name,conn_url,api_url,expiration,ttl')):
|
class Member(namedtuple('Member', 'index,name,session,data')):
|
||||||
|
|
||||||
"""Immutable object (namedtuple) which represents single member of PostgreSQL cluster.
|
"""Immutable object (namedtuple) which represents single member of PostgreSQL cluster.
|
||||||
Consists of the following fields:
|
Consists of the following fields:
|
||||||
:param index: modification index of a given member key in a Configuration Store
|
:param index: modification index of a given member key in a Configuration Store
|
||||||
:param name: name of PostgreSQL cluster member
|
:param name: name of PostgreSQL cluster member
|
||||||
:param conn_url: connection string containing host, user and password which could be used to access this member.
|
:param session: either session id or just ttl in seconds
|
||||||
:param api_url: REST API url of patroni instance
|
:param data: arbitrary data i.e. conn_url, api_url, xlog location, state, role, tags, etc...
|
||||||
:param expiration: expiration time of given member key
|
|
||||||
:param ttl: ttl of given member key in seconds"""
|
|
||||||
|
|
||||||
def real_ttl(self):
|
There are two mandatory keys in a data:
|
||||||
return calculate_ttl(self.expiration) or -1
|
conn_url: connection string containing host, user and password which could be used to access this member.
|
||||||
|
api_url: REST API url of patroni instance"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_node(index, name, session, data):
|
||||||
|
"""
|
||||||
|
>>> Member.from_node(-1, '', '', '{"conn_url": "postgres://foo@bar/postgres"}') is not None
|
||||||
|
True
|
||||||
|
>>> Member.from_node(-1, '', '', '{')
|
||||||
|
Member(index=-1, name='', session='', data={})
|
||||||
|
"""
|
||||||
|
if data.startswith('postgres'):
|
||||||
|
conn_url, api_url = parse_connection_string(data)
|
||||||
|
data = {'conn_url': conn_url, 'api_url': api_url}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data = json.loads(data)
|
||||||
|
except:
|
||||||
|
data = {}
|
||||||
|
return Member(index, name, session, data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def conn_url(self):
|
||||||
|
return self.data.get('conn_url', None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_url(self):
|
||||||
|
return self.data.get('api_url', None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nofailover(self):
|
||||||
|
return self.data.get('tags', {}).get('nofailover', False)
|
||||||
|
|
||||||
|
|
||||||
class Leader(namedtuple('Leader', 'index,expiration,ttl,member')):
|
class Leader(namedtuple('Leader', 'index,session,member')):
|
||||||
|
|
||||||
"""Immutable object (namedtuple) which represents leader key.
|
"""Immutable object (namedtuple) which represents leader key.
|
||||||
Consists of the following fields:
|
Consists of the following fields:
|
||||||
:param index: modification index of a leader key in a Configuration Store
|
:param index: modification index of a leader key in a Configuration Store
|
||||||
:param expiration: expiration time of the leader key
|
:param session: either session id or just ttl in seconds
|
||||||
:param ttl: ttl of the leader key
|
|
||||||
:param member: reference to a `Member` object which represents current leader (see `Cluster.members`)"""
|
:param member: reference to a `Member` object which represents current leader (see `Cluster.members`)"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -56,7 +85,15 @@ class Leader(namedtuple('Leader', 'index,expiration,ttl,member')):
|
|||||||
return self.member.conn_url
|
return self.member.conn_url
|
||||||
|
|
||||||
|
|
||||||
class Cluster(namedtuple('Cluster', 'initialize,leader,last_leader_operation,members')):
|
class Failover(namedtuple('Failover', 'index,leader,member')):
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_node(index, value):
|
||||||
|
t = [a.strip() for a in value.split(':')] + ['']
|
||||||
|
return Failover(index, t[0], t[1]) if t[0] or t[1] else None
|
||||||
|
|
||||||
|
|
||||||
|
class Cluster(namedtuple('Cluster', 'initialize,leader,last_leader_operation,members,failover')):
|
||||||
|
|
||||||
"""Immutable object (namedtuple) which represents PostgreSQL cluster.
|
"""Immutable object (namedtuple) which represents PostgreSQL cluster.
|
||||||
Consists of the following fields:
|
Consists of the following fields:
|
||||||
@@ -64,7 +101,8 @@ class Cluster(namedtuple('Cluster', 'initialize,leader,last_leader_operation,mem
|
|||||||
:param leader: `Leader` object which represents current leader of the cluster
|
:param leader: `Leader` object which represents current leader of the cluster
|
||||||
:param last_leader_operation: int or long object containing position of last known leader operation.
|
:param last_leader_operation: int or long object containing position of last known leader operation.
|
||||||
This value is stored in `/optime/leader` key
|
This value is stored in `/optime/leader` key
|
||||||
:param members: list of Member object, all PostgreSQL cluster members including leader"""
|
:param members: list of Member object, all PostgreSQL cluster members including leader
|
||||||
|
:param failover: reference to `Failover` object"""
|
||||||
|
|
||||||
def is_unlocked(self):
|
def is_unlocked(self):
|
||||||
return not (self.leader and self.leader.name)
|
return not (self.leader and self.leader.name)
|
||||||
@@ -74,6 +112,13 @@ class AbstractDCS:
|
|||||||
|
|
||||||
__metaclass__ = abc.ABCMeta
|
__metaclass__ = abc.ABCMeta
|
||||||
|
|
||||||
|
_INITIALIZE = 'initialize'
|
||||||
|
_LEADER = 'leader'
|
||||||
|
_FAILOVER = 'failover'
|
||||||
|
_MEMBERS = 'members/'
|
||||||
|
_OPTIME = 'optime'
|
||||||
|
_LEADER_OPTIME = _OPTIME + '/' + _LEADER
|
||||||
|
|
||||||
def __init__(self, name, config):
|
def __init__(self, name, config):
|
||||||
"""
|
"""
|
||||||
:param name: name of current instance (the same value as `~Postgresql.name`)
|
:param name: name of current instance (the same value as `~Postgresql.name`)
|
||||||
@@ -81,27 +126,80 @@ class AbstractDCS:
|
|||||||
i.e.: `zookeeper` for zookeeper, `etcd` for etcd, etc...
|
i.e.: `zookeeper` for zookeeper, `etcd` for etcd, etc...
|
||||||
"""
|
"""
|
||||||
self._name = name
|
self._name = name
|
||||||
self._scope = config['scope']
|
self._namespace = '/{}'.format(config.get('namespace', '/service/').strip('/'))
|
||||||
self._base_path = '/service/' + self._scope
|
self._base_path = '/'.join([self._namespace, config['scope']])
|
||||||
|
|
||||||
|
self._cluster = None
|
||||||
|
self._cluster_thread_lock = Lock()
|
||||||
|
self.event = Event()
|
||||||
|
|
||||||
def client_path(self, path):
|
def client_path(self, path):
|
||||||
return self._base_path + path
|
return '/'.join([self._base_path, path.lstrip('/')])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def initialize_path(self):
|
||||||
|
return self.client_path(self._INITIALIZE)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def members_path(self):
|
||||||
|
return self.client_path(self._MEMBERS)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def member_path(self):
|
||||||
|
return self.client_path(self._MEMBERS + self._name)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def leader_path(self):
|
||||||
|
return self.client_path(self._LEADER)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failover_path(self):
|
||||||
|
return self.client_path(self._FAILOVER)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def leader_optime_path(self):
|
||||||
|
return self.client_path(self._LEADER_OPTIME)
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
|
def _load_cluster(self):
|
||||||
|
"""Internally this method should build `Cluster` object which
|
||||||
|
represents current state and topology of the cluster in DCS.
|
||||||
|
this method supposed to be called only by `get_cluster` method.
|
||||||
|
|
||||||
|
raise `~DCSError` in case of communication or other problems with DCS.
|
||||||
|
If the current node was running as a master and exception raised,
|
||||||
|
instance would be demoted."""
|
||||||
|
|
||||||
def get_cluster(self):
|
def get_cluster(self):
|
||||||
""":returns: `Cluster` object which represent current state and topology of the cluster
|
with self._cluster_thread_lock:
|
||||||
raise `~DCSError` in case of communication or other problems with DCS. If current instance was
|
try:
|
||||||
running as a master and exception raised instance would be demoted."""
|
self._load_cluster()
|
||||||
|
except:
|
||||||
|
self._cluster = None
|
||||||
|
raise
|
||||||
|
return self._cluster
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cluster(self):
|
||||||
|
with self._cluster_thread_lock:
|
||||||
|
return self._cluster
|
||||||
|
|
||||||
|
def reset_cluster(self):
|
||||||
|
with self._cluster_thread_lock:
|
||||||
|
self._cluster = None
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def update_leader(self, state_handler):
|
def write_leader_optime(self, last_operation):
|
||||||
"""Update leader key (or session) ttl and `/optime/leader` key in DCS.
|
"""write current xlog location into `/optime/leader` key in DCS
|
||||||
|
:param last_operation: absolute xlog location in bytes"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def update_leader(self):
|
||||||
|
"""Update leader key (or session) ttl
|
||||||
|
|
||||||
:param state_handler: reference to `Postgresql` object
|
|
||||||
:returns: `!True` if leader key (or session) has been updated successfully.
|
:returns: `!True` if leader key (or session) has been updated successfully.
|
||||||
If not, `!False` must be returned and current instance would be demoted.
|
If not, `!False` must be returned and current instance would be demoted.
|
||||||
|
|
||||||
If you failed to update `/optime/leader` this error is not critical and you can return `!True`
|
|
||||||
You have to use CAS (Compare And Swap) operation in order to update leader key,
|
You have to use CAS (Compare And Swap) operation in order to update leader key,
|
||||||
for example for etcd `prevValue` parameter must be used."""
|
for example for etcd `prevValue` parameter must be used."""
|
||||||
|
|
||||||
@@ -114,6 +212,13 @@ class AbstractDCS:
|
|||||||
Key must be created atomically. In case if key already exists it should not be
|
Key must be created atomically. In case if key already exists it should not be
|
||||||
overwritten and `!False` must be returned"""
|
overwritten and `!False` must be returned"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def set_failover_value(self, value, index=None):
|
||||||
|
"""Create or update `/failover` key"""
|
||||||
|
|
||||||
|
def manual_failover(self, leader, member, index=None):
|
||||||
|
return self.set_failover_value(leader + (':' + member if member else ''), index)
|
||||||
|
|
||||||
def current_leader(self):
|
def current_leader(self):
|
||||||
try:
|
try:
|
||||||
cluster = self.get_cluster()
|
cluster = self.get_cluster()
|
||||||
@@ -139,12 +244,14 @@ class AbstractDCS:
|
|||||||
overwriting the key if necessary."""
|
overwriting the key if necessary."""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def race(self, path):
|
def initialize(self, create_new=True, sysid=""):
|
||||||
"""Race for cluster initialization.
|
"""Race for cluster initialization.
|
||||||
:param path: usually this is just '/initialize'
|
|
||||||
|
:param create_new: False if the key should already exist (in the case we are setting the system_id)
|
||||||
|
:param sysid: PostgreSQL cluster system identifier, if specified, is written to the key
|
||||||
:returns: `!True` if key has been created successfully.
|
:returns: `!True` if key has been created successfully.
|
||||||
|
|
||||||
this method should create atomically `path` key and return `!True`
|
this method should create atomically initialize key and return `!True`
|
||||||
otherwise it should return `!False`"""
|
otherwise it should return `!False`"""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
@@ -152,5 +259,16 @@ class AbstractDCS:
|
|||||||
"""Voluntarily remove leader key from DCS
|
"""Voluntarily remove leader key from DCS
|
||||||
This method should remove leader key if current instance is the leader"""
|
This method should remove leader key if current instance is the leader"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def cancel_initialization(self):
|
||||||
|
""" Removes the initialize key for a cluster """
|
||||||
|
|
||||||
def watch(self, timeout):
|
def watch(self, timeout):
|
||||||
sleep(timeout)
|
"""If the current node is a master it should just sleep.
|
||||||
|
Any other node should watch for changes of leader key with a given timeout
|
||||||
|
|
||||||
|
:param timeout: timeout in seconds
|
||||||
|
:returns: `!True` if you would like to reschedule the next run of ha cycle"""
|
||||||
|
|
||||||
|
self.event.wait(timeout)
|
||||||
|
return self.event.isSet()
|
||||||
|
|||||||
+60
-42
@@ -10,7 +10,8 @@ import urllib3
|
|||||||
|
|
||||||
from dns.exception import DNSException
|
from dns.exception import DNSException
|
||||||
from dns import resolver
|
from dns import resolver
|
||||||
from patroni.dcs import AbstractDCS, Cluster, DCSError, Leader, Member, parse_connection_string
|
from patroni.dcs import AbstractDCS, Cluster, Failover, Leader, Member
|
||||||
|
from patroni.exceptions import DCSError
|
||||||
from patroni.utils import Retry, RetryFailedError, sleep
|
from patroni.utils import Retry, RetryFailedError, sleep
|
||||||
from requests.exceptions import RequestException
|
from requests.exceptions import RequestException
|
||||||
|
|
||||||
@@ -51,7 +52,11 @@ class Client(etcd.Client):
|
|||||||
def api_execute(self, path, method, **kwargs):
|
def api_execute(self, path, method, **kwargs):
|
||||||
# Update machines_cache if previous attempt of update has failed
|
# Update machines_cache if previous attempt of update has failed
|
||||||
self._update_machines_cache and self._load_machines_cache()
|
self._update_machines_cache and self._load_machines_cache()
|
||||||
return super(Client, self).api_execute(path, method, **kwargs)
|
try:
|
||||||
|
return super(Client, self).api_execute(path, method, **kwargs)
|
||||||
|
except etcd.EtcdConnectionFailed:
|
||||||
|
self._update_machines_cache = True
|
||||||
|
raise
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_srv_record(host):
|
def get_srv_record(host):
|
||||||
@@ -80,7 +85,7 @@ class Client(etcd.Client):
|
|||||||
for host, port in self.get_srv_record(discovery_srv):
|
for host, port in self.get_srv_record(discovery_srv):
|
||||||
url = '{}://{}:{}/members'.format(self._protocol, host, port)
|
url = '{}://{}:{}/members'.format(self._protocol, host, port)
|
||||||
try:
|
try:
|
||||||
response = requests.get(url)
|
response = requests.get(url, timeout=5)
|
||||||
if response.ok:
|
if response.ok:
|
||||||
for member in response.json():
|
for member in response.json():
|
||||||
ret.extend(member['clientURLs'])
|
ret.extend(member['clientURLs'])
|
||||||
@@ -138,6 +143,10 @@ def catch_etcd_errors(func):
|
|||||||
return not func(*args, **kwargs) is None
|
return not func(*args, **kwargs) is None
|
||||||
except (RetryFailedError, etcd.EtcdException):
|
except (RetryFailedError, etcd.EtcdException):
|
||||||
return False
|
return False
|
||||||
|
except:
|
||||||
|
logger.exception("")
|
||||||
|
raise EtcdError("unexpected error")
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
@@ -145,15 +154,13 @@ class Etcd(AbstractDCS):
|
|||||||
|
|
||||||
def __init__(self, name, config):
|
def __init__(self, name, config):
|
||||||
super(Etcd, self).__init__(name, config)
|
super(Etcd, self).__init__(name, config)
|
||||||
self.ttl = config['ttl']
|
self.ttl = config.get('ttl', 30)
|
||||||
self.member_ttl = config.get('member_ttl', 3600)
|
|
||||||
self._retry = Retry(deadline=10, max_delay=1, max_tries=-1,
|
self._retry = Retry(deadline=10, max_delay=1, max_tries=-1,
|
||||||
retry_exceptions=(etcd.EtcdConnectionFailed,
|
retry_exceptions=(etcd.EtcdConnectionFailed,
|
||||||
etcd.EtcdLeaderElectionInProgress,
|
etcd.EtcdLeaderElectionInProgress,
|
||||||
etcd.EtcdWatcherCleared,
|
etcd.EtcdWatcherCleared,
|
||||||
etcd.EtcdEventIndexCleared))
|
etcd.EtcdEventIndexCleared))
|
||||||
self.client = self.get_etcd_client(config)
|
self.client = self.get_etcd_client(config)
|
||||||
self.cluster = None
|
|
||||||
|
|
||||||
def retry(self, *args, **kwargs):
|
def retry(self, *args, **kwargs):
|
||||||
return self._retry.copy()(*args, **kwargs)
|
return self._retry.copy()(*args, **kwargs)
|
||||||
@@ -170,53 +177,54 @@ class Etcd(AbstractDCS):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def member(node):
|
def member(node):
|
||||||
conn_url, api_url = parse_connection_string(node.value)
|
return Member.from_node(node.modifiedIndex, os.path.basename(node.key), node.ttl, node.value)
|
||||||
return Member(node.modifiedIndex, os.path.basename(node.key), conn_url, api_url, node.expiration, node.ttl)
|
|
||||||
|
|
||||||
def get_cluster(self):
|
def _load_cluster(self):
|
||||||
try:
|
try:
|
||||||
result = self.retry(self.client.read, self.client_path(''), recursive=True)
|
result = self.retry(self.client.read, self.client_path(''), recursive=True)
|
||||||
nodes = {os.path.relpath(node.key, result.key): node for node in result.leaves}
|
nodes = {os.path.relpath(node.key, result.key): node for node in result.leaves}
|
||||||
|
|
||||||
# get initialize flag
|
# get initialize flag
|
||||||
initialize = bool(nodes.get('initialize', False))
|
initialize = nodes.get(self._INITIALIZE, None)
|
||||||
|
initialize = initialize and initialize.value
|
||||||
|
|
||||||
# get last leader operation
|
# get last leader operation
|
||||||
last_leader_operation = nodes.get('optime/leader', None)
|
last_leader_operation = nodes.get(self._LEADER_OPTIME, None)
|
||||||
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation.value)
|
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation.value)
|
||||||
|
|
||||||
# get list of members
|
# get list of members
|
||||||
members = [self.member(n) for k, n in nodes.items() if k.startswith('members/') and len(k.split('/')) == 2]
|
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||||
|
|
||||||
# get leader
|
# get leader
|
||||||
leader = nodes.get('leader', None)
|
leader = nodes.get(self._LEADER, None)
|
||||||
if leader:
|
if leader:
|
||||||
member = Member(-1, leader.value, None, None, None, None)
|
member = Member(-1, leader.value, None, {})
|
||||||
member = ([m for m in members if m.name == leader.value] or [member])[0]
|
member = ([m for m in members if m.name == leader.value] or [member])[0]
|
||||||
leader = Leader(leader.modifiedIndex, leader.expiration, leader.ttl, member)
|
leader = Leader(leader.modifiedIndex, leader.ttl, member)
|
||||||
|
|
||||||
self.cluster = Cluster(initialize, leader, last_leader_operation, members)
|
# failover key
|
||||||
|
failover = nodes.get(self._FAILOVER, None)
|
||||||
|
if failover:
|
||||||
|
failover = Failover.from_node(failover.modifiedIndex, failover.value)
|
||||||
|
|
||||||
|
self._cluster = Cluster(initialize, leader, last_leader_operation, members, failover)
|
||||||
except etcd.EtcdKeyNotFound:
|
except etcd.EtcdKeyNotFound:
|
||||||
self.cluster = Cluster(False, None, None, [])
|
self._cluster = Cluster(False, None, None, [], None)
|
||||||
except:
|
except:
|
||||||
self.cluster = None
|
|
||||||
logger.exception('get_cluster')
|
logger.exception('get_cluster')
|
||||||
raise EtcdError('Etcd is not responding properly')
|
raise EtcdError('Etcd is not responding properly')
|
||||||
return self.cluster
|
|
||||||
|
|
||||||
@catch_etcd_errors
|
@catch_etcd_errors
|
||||||
def touch_member(self, connection_string, ttl=None):
|
def touch_member(self, connection_string, ttl=None):
|
||||||
return self.retry(self.client.set, self.client_path('/members/' + self._name),
|
return self.retry(self.client.set, self.member_path, connection_string, ttl or self.ttl)
|
||||||
connection_string, ttl or self.member_ttl)
|
|
||||||
|
|
||||||
@catch_etcd_errors
|
@catch_etcd_errors
|
||||||
def take_leader(self):
|
def take_leader(self):
|
||||||
return self.retry(self.client.set, self.client_path('/leader'), self._name, self.ttl)
|
return self.retry(self.client.set, self.leader_path, self._name, self.ttl)
|
||||||
|
|
||||||
def attempt_to_acquire_leader(self):
|
def attempt_to_acquire_leader(self):
|
||||||
try:
|
try:
|
||||||
return not self.retry(self.client.write, self.client_path('/leader'),
|
return bool(self.retry(self.client.write, self.leader_path, self._name, ttl=self.ttl, prevExist=False))
|
||||||
self._name, ttl=self.ttl, prevExist=False) is None
|
|
||||||
except etcd.EtcdAlreadyExist:
|
except etcd.EtcdAlreadyExist:
|
||||||
logger.info('Could not take out TTL lock')
|
logger.info('Could not take out TTL lock')
|
||||||
except (RetryFailedError, etcd.EtcdException):
|
except (RetryFailedError, etcd.EtcdException):
|
||||||
@@ -224,41 +232,51 @@ class Etcd(AbstractDCS):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
@catch_etcd_errors
|
@catch_etcd_errors
|
||||||
def write_leader_optime(self, state_handler):
|
def set_failover_value(self, value, index=None):
|
||||||
return self.client.set(self.client_path('/optime/leader'), state_handler.last_operation())
|
return self.client.write(self.failover_path, value, prevIndex=index or 0)
|
||||||
|
|
||||||
@catch_etcd_errors
|
@catch_etcd_errors
|
||||||
def update_leader(self, state_handler):
|
def write_leader_optime(self, last_operation):
|
||||||
ret = self.retry(self.client.test_and_set, self.client_path('/leader'), self._name, self._name, self.ttl)
|
return self.client.set(self.leader_optime_path, last_operation)
|
||||||
ret and self.write_leader_optime(state_handler)
|
|
||||||
return ret
|
|
||||||
|
|
||||||
@catch_etcd_errors
|
@catch_etcd_errors
|
||||||
def race(self, path):
|
def update_leader(self):
|
||||||
return self.retry(self.client.write, self.client_path(path), self._name, prevExist=False)
|
return self.retry(self.client.test_and_set, self.leader_path, self._name, self._name, self.ttl)
|
||||||
|
|
||||||
|
@catch_etcd_errors
|
||||||
|
def initialize(self, create_new=True, sysid=""):
|
||||||
|
return self.retry(self.client.write, self.initialize_path, sysid, prevExist=(not create_new))
|
||||||
|
|
||||||
@catch_etcd_errors
|
@catch_etcd_errors
|
||||||
def delete_leader(self):
|
def delete_leader(self):
|
||||||
return self.client.delete(self.client_path('/leader'), prevValue=self._name)
|
return self.client.delete(self.leader_path, prevValue=self._name)
|
||||||
|
|
||||||
|
@catch_etcd_errors
|
||||||
|
def cancel_initialization(self):
|
||||||
|
return self.retry(self.client.delete, self.initialize_path)
|
||||||
|
|
||||||
def watch(self, timeout):
|
def watch(self, timeout):
|
||||||
|
cluster = self.cluster
|
||||||
# watch on leader key changes if it is defined and current node is not lock owner
|
# watch on leader key changes if it is defined and current node is not lock owner
|
||||||
if self.cluster and self.cluster.leader and self.cluster.leader.name != self._name:
|
if cluster and cluster.leader and cluster.leader.name != self._name:
|
||||||
end_time = time.time() + timeout
|
end_time = time.time() + timeout
|
||||||
index = self.cluster.leader.index
|
index = cluster.leader.index
|
||||||
|
|
||||||
while index and timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect
|
while index and timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect
|
||||||
try:
|
try:
|
||||||
res = self.client.watch(self.client_path('/leader'), index=index + 1, timeout=timeout)
|
self.client.watch(self.leader_path, index=index + 1, timeout=timeout + 0.5)
|
||||||
if res.action not in ['set', 'compareAndSwap'] or res.value != self.cluster.leader.name:
|
# Synchronous work of all cluster members with etcd is less expensive
|
||||||
return
|
# than reestablishing http connection every time from every replica.
|
||||||
index = res.modifiedIndex
|
return True
|
||||||
except urllib3.exceptions.TimeoutError:
|
except urllib3.exceptions.TimeoutError:
|
||||||
self.client.http.clear()
|
self.client.http.clear()
|
||||||
return
|
return False
|
||||||
except etcd.EtcdException:
|
except etcd.EtcdException:
|
||||||
index = None
|
logging.exception('watch')
|
||||||
|
|
||||||
timeout = end_time - time.time()
|
timeout = end_time - time.time()
|
||||||
|
|
||||||
timeout > 0 and super(Etcd, self).watch(timeout)
|
try:
|
||||||
|
return super(Etcd, self).watch(timeout)
|
||||||
|
finally:
|
||||||
|
self.event.clear()
|
||||||
|
|||||||
+13
-1
@@ -7,11 +7,23 @@ class PatroniException(Exception):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""
|
"""
|
||||||
>>> str(DCSError('foo'))
|
>>> str(PatroniException('foo'))
|
||||||
"'foo'"
|
"'foo'"
|
||||||
"""
|
"""
|
||||||
return repr(self.value)
|
return repr(self.value)
|
||||||
|
|
||||||
|
|
||||||
|
class PatroniCtlException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresException(PatroniException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class DCSError(PatroniException):
|
class DCSError(PatroniException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresConnectionException(PostgresException):
|
||||||
|
pass
|
||||||
|
|||||||
+401
-63
@@ -1,26 +1,31 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import psycopg2
|
||||||
|
import requests
|
||||||
|
import sys
|
||||||
|
|
||||||
from patroni.dcs import DCSError
|
from patroni.async_executor import AsyncExecutor
|
||||||
from psycopg2 import InterfaceError, OperationalError
|
from patroni.exceptions import DCSError, PostgresConnectionException
|
||||||
|
from multiprocessing.pool import ThreadPool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Ha:
|
class Ha:
|
||||||
|
|
||||||
def __init__(self, state_handler, etcd):
|
def __init__(self, patroni):
|
||||||
self.state_handler = state_handler
|
self.patroni = patroni
|
||||||
self.dcs = etcd
|
self.state_handler = patroni.postgresql
|
||||||
|
self.dcs = patroni.dcs
|
||||||
self.cluster = None
|
self.cluster = None
|
||||||
self.old_cluster = None
|
self.old_cluster = None
|
||||||
|
self._async_executor = AsyncExecutor()
|
||||||
|
|
||||||
def load_cluster_from_dcs(self):
|
def load_cluster_from_dcs(self):
|
||||||
cluster = self.dcs.get_cluster()
|
cluster = self.dcs.get_cluster()
|
||||||
|
|
||||||
# We want to keep the state of cluster when it was healhy
|
# We want to keep the state of cluster when it was healhy
|
||||||
if cluster.is_unlocked() and self.cluster and not self.cluster.is_unlocked():
|
if not cluster.is_unlocked() or not self.old_cluster:
|
||||||
self.old_cluster = self.cluster
|
|
||||||
if not self.old_cluster:
|
|
||||||
self.old_cluster = cluster
|
self.old_cluster = cluster
|
||||||
self.cluster = cluster
|
self.cluster = cluster
|
||||||
|
|
||||||
@@ -28,74 +33,407 @@ class Ha:
|
|||||||
return self.dcs.attempt_to_acquire_leader()
|
return self.dcs.attempt_to_acquire_leader()
|
||||||
|
|
||||||
def update_lock(self):
|
def update_lock(self):
|
||||||
return self.dcs.update_leader(self.state_handler)
|
ret = self.dcs.update_leader()
|
||||||
|
if ret:
|
||||||
|
try:
|
||||||
|
self.dcs.write_leader_optime(self.state_handler.last_operation())
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return ret
|
||||||
|
|
||||||
def has_lock(self):
|
def has_lock(self):
|
||||||
lock_owner = self.cluster.leader and self.cluster.leader.name
|
lock_owner = self.cluster.leader and self.cluster.leader.name
|
||||||
logger.info('Lock owner: %s; I am %s', lock_owner, self.state_handler.name)
|
logger.info('Lock owner: %s; I am %s', lock_owner, self.state_handler.name)
|
||||||
return lock_owner == self.state_handler.name
|
return lock_owner == self.state_handler.name
|
||||||
|
|
||||||
def demote(self):
|
def touch_member(self):
|
||||||
return self.state_handler.demote(self.cluster.leader)
|
data = {
|
||||||
|
'conn_url': self.state_handler.connection_string,
|
||||||
|
'api_url': self.patroni.api.connection_string,
|
||||||
|
'state': self.state_handler.state,
|
||||||
|
'role': self.state_handler.role,
|
||||||
|
'tags': self.patroni.tags
|
||||||
|
}
|
||||||
|
if data['state'] in ['running', 'restarting', 'starting']:
|
||||||
|
try:
|
||||||
|
data['xlog_location'] = self.state_handler.xlog_position()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self.dcs.touch_member(json.dumps(data, separators=(',', ':')))
|
||||||
|
|
||||||
def follow_the_leader(self):
|
def copy_backup_from_leader(self, leader):
|
||||||
return self.state_handler.follow_the_leader(self.cluster.leader)
|
if self.state_handler.bootstrap(leader):
|
||||||
|
logger.info('bootstrapped from leader')
|
||||||
|
else:
|
||||||
|
self.state_handler.stop('immediate')
|
||||||
|
self.state_handler.remove_data_directory()
|
||||||
|
logger.error('failed to bootstrap from leader')
|
||||||
|
|
||||||
def run_cycle(self):
|
def bootstrap(self):
|
||||||
|
if not self.cluster.is_unlocked(): # cluster already has leader
|
||||||
|
self._async_executor.schedule('bootstrap from leader')
|
||||||
|
self._async_executor.run_async(self.copy_backup_from_leader, args=(self.cluster.leader, ))
|
||||||
|
return 'trying to bootstrap from leader'
|
||||||
|
elif not self.cluster.initialize and not self.patroni.nofailover: # no initialize key
|
||||||
|
if self.dcs.initialize(create_new=True): # race for initialization
|
||||||
|
try:
|
||||||
|
self.state_handler.bootstrap()
|
||||||
|
self.dcs.initialize(create_new=False, sysid=self.state_handler.sysid)
|
||||||
|
except: # initdb or start failed
|
||||||
|
# remove initialization key and give a chance to other members
|
||||||
|
logger.info("removing initialize key after failed attempt to initialize the cluster")
|
||||||
|
self.dcs.cancel_initialization()
|
||||||
|
self.state_handler.stop('immediate')
|
||||||
|
self.state_handler.move_data_directory()
|
||||||
|
raise
|
||||||
|
self.dcs.take_leader()
|
||||||
|
return 'initialized a new cluster'
|
||||||
|
else:
|
||||||
|
return 'failed to acquire initialize lock'
|
||||||
|
else:
|
||||||
|
return 'waiting for leader to bootstrap'
|
||||||
|
|
||||||
|
def recover(self):
|
||||||
|
has_lock = self.has_lock()
|
||||||
|
|
||||||
|
# try to see if we are the former master that crashed. If so - we likely need to run pg_rewind
|
||||||
|
# in order to join the former standby being promoted.
|
||||||
|
pg_controldata = self.state_handler.controldata()
|
||||||
|
if not has_lock and pg_controldata and\
|
||||||
|
pg_controldata.get('Database cluster state', '') == 'in production': # crashed master
|
||||||
|
self.state_handler.require_rewind()
|
||||||
|
|
||||||
|
# XXX: follow the leader calls stop, which might take quite some time.
|
||||||
|
# perhaps we should run sync asynchronously
|
||||||
|
# (we still need the exit code from follow_the_leader)
|
||||||
|
ret = self.state_handler.follow_the_leader(None if has_lock else self.cluster.leader, recovery=True)
|
||||||
|
if not ret:
|
||||||
|
if not has_lock:
|
||||||
|
return 'failed to start postgres'
|
||||||
|
self.dcs.delete_leader()
|
||||||
|
self.dcs.reset_cluster()
|
||||||
|
return 'removed leader key after trying and failing to start postgres'
|
||||||
|
if not has_lock:
|
||||||
|
return 'started as a secondary'
|
||||||
|
logger.info('started as readonly because i had the session lock')
|
||||||
|
self.load_cluster_from_dcs()
|
||||||
|
|
||||||
|
def follow_the_leader(self, demote_reason, follow_reason, refresh=True):
|
||||||
|
refresh and self.load_cluster_from_dcs()
|
||||||
|
ret = demote_reason if self.state_handler.is_leader() else follow_reason
|
||||||
|
leader = self.cluster.leader
|
||||||
|
leader = None if (leader and leader.name) == self.state_handler.name else leader
|
||||||
|
if not self.state_handler.check_recovery_conf(leader):
|
||||||
|
self._async_executor.schedule('changing primary_conninfo and restarting')
|
||||||
|
self._async_executor.run_async(self.state_handler.follow_the_leader, (leader, ))
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def enforce_master_role(self, message, promote_message):
|
||||||
|
if self.state_handler.is_leader() or self.state_handler.role == 'master':
|
||||||
|
return message
|
||||||
|
else:
|
||||||
|
self.state_handler.promote()
|
||||||
|
self.touch_member()
|
||||||
|
return promote_message
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fetch_node_status(member):
|
||||||
|
"""This function perform http get request on member.api_url and fetches its status
|
||||||
|
:returns: tuple(`member`, reachable, in_recovery, xlog_location)
|
||||||
|
|
||||||
|
reachable - `!False` if the node is not reachable or is not responding with correct JSON
|
||||||
|
in_recovery - `!True` if pg_is_in_recovery() == true
|
||||||
|
xlog_location - value of `replayed_location` or `location` from JSON, dependin on its role.
|
||||||
|
tags - dictionary with values of different tags (i.e. nofailover)
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(member.api_url, timeout=2, verify=False)
|
||||||
|
logger.info('Got response from %s %s: %s', member.name, member.api_url, response.content)
|
||||||
|
json = response.json()
|
||||||
|
is_master = json['role'] == 'master'
|
||||||
|
xlog_location = json['xlog']['location' if is_master else 'replayed_location']
|
||||||
|
tags = json.get('tags', dict())
|
||||||
|
return (member, True, not is_master, xlog_location, tags)
|
||||||
|
except:
|
||||||
|
logging.exception('request failed: GET %s', member.api_url)
|
||||||
|
return (member, False, None, 0, {})
|
||||||
|
|
||||||
|
def fetch_nodes_statuses(self, members):
|
||||||
|
pool = ThreadPool(len(members))
|
||||||
|
results = pool.map(self.fetch_node_status, members) # Run API calls on members in parallel
|
||||||
|
pool.close()
|
||||||
|
pool.join()
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _is_healthiest_node(self, members, check_replication_lag=True):
|
||||||
|
"""This method tries to determine whether I am healthy enough to became a new leader candidate or not."""
|
||||||
|
|
||||||
|
if self.state_handler.is_leader():
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self.patroni.nofailover is True:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if check_replication_lag and not self.state_handler.check_replication_lag(self.cluster.last_leader_operation):
|
||||||
|
return False # Too far behind last reported xlog location on master
|
||||||
|
|
||||||
|
# Prepare list of nodes to run check against
|
||||||
|
members = [m for m in members if m.name != self.state_handler.name and not m.nofailover and m.api_url]
|
||||||
|
|
||||||
|
if members:
|
||||||
|
my_xlog_location = self.state_handler.xlog_position()
|
||||||
|
for member, reachable, in_recovery, xlog_location, tags in self.fetch_nodes_statuses(members):
|
||||||
|
if reachable and not tags.get('nofailover', False): # If the node is unreachable it's not healhy
|
||||||
|
if not in_recovery:
|
||||||
|
logger.warning('Master (%s) is still alive', member.name)
|
||||||
|
return False
|
||||||
|
if my_xlog_location < xlog_location:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_failover_possible(self, members):
|
||||||
|
ret = False
|
||||||
|
members = [m for m in members if m.name != self.state_handler.name and not m.nofailover and m.api_url]
|
||||||
|
if members:
|
||||||
|
for member, reachable, in_recovery, xlog_location, tags in self.fetch_nodes_statuses(members):
|
||||||
|
if reachable and not tags.get('nofailover', False):
|
||||||
|
ret = True # TODO: check xlog_location
|
||||||
|
elif not reachable:
|
||||||
|
logger.info('Member %s is not reachable', member.name)
|
||||||
|
elif tags.get('nofailover', False):
|
||||||
|
logger.info('Member %s is not allowed to promote', member.name)
|
||||||
|
else:
|
||||||
|
logger.warning('manual failover: members list is empty')
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def manual_failover_process_no_leader(self):
|
||||||
|
failover = self.cluster.failover
|
||||||
|
if failover.member: # manual failover to specific member
|
||||||
|
if failover.member == self.state_handler.name: # manual failover to me
|
||||||
|
return True
|
||||||
|
|
||||||
|
# find specific node and check that it is healthy
|
||||||
|
members = [m for m in self.cluster.members if m.name == failover.member]
|
||||||
|
if members:
|
||||||
|
member, reachable, in_recovery, xlog_location, tags = self.fetch_node_status(members[0])
|
||||||
|
if reachable and not tags.get('nofailover', False): # node is healthy
|
||||||
|
logger.info('manual failover: to %s, i am %s', member.name, self.state_handler.name)
|
||||||
|
return False
|
||||||
|
# we wanted to failover to specific member but it is not healthy
|
||||||
|
if not reachable:
|
||||||
|
logger.warning('manual failover: member %s is unhealthy', member.name)
|
||||||
|
elif tags.get('nofailover', False):
|
||||||
|
logger.warning('manual failover: member %s is not allowed to promote', member.name)
|
||||||
|
|
||||||
|
# at this point we should consider all members as a candidates for failover
|
||||||
|
# i.e. we assume that failover.member is None
|
||||||
|
|
||||||
|
# try to pick some other members to failover and check that they are healthy
|
||||||
|
if failover.leader:
|
||||||
|
if self.state_handler.name == failover.leader: # I was the leader
|
||||||
|
# exclude me and desired member which is unhealthy (failover.member can be None)
|
||||||
|
members = [m for m in self.cluster.members if m.name not in (failover.member, failover.leader)]
|
||||||
|
if self.is_failover_possible(members): # check that there are healthy members
|
||||||
|
return False
|
||||||
|
else: # I was the leader and it looks like currently I am the only healthy member
|
||||||
|
return True
|
||||||
|
|
||||||
|
# at this point we assume that our node is a candidate for a failover among all nodes except former leader
|
||||||
|
|
||||||
|
# exclude former leader from the list (failover.leader can be None)
|
||||||
|
members = [m for m in self.cluster.members if m.name != failover.leader]
|
||||||
|
return self._is_healthiest_node(members, check_replication_lag=False)
|
||||||
|
|
||||||
|
def is_healthiest_node(self):
|
||||||
|
|
||||||
|
if self.state_handler.is_leader(): # leader is always the healthiest
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self.patroni.nofailover: # nofailover tag makes node always unhealthy
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.cluster.failover:
|
||||||
|
return self.manual_failover_process_no_leader()
|
||||||
|
|
||||||
|
# run usual health check
|
||||||
|
members = {m.name: m for m in self.cluster.members + self.old_cluster.members}
|
||||||
|
return self._is_healthiest_node(members.values())
|
||||||
|
|
||||||
|
def demote(self, delete_leader=True):
|
||||||
|
if delete_leader:
|
||||||
|
self.state_handler.stop()
|
||||||
|
self.dcs.delete_leader()
|
||||||
|
self.touch_member()
|
||||||
|
self.dcs.reset_cluster()
|
||||||
|
self.state_handler.follow_the_leader(None)
|
||||||
|
|
||||||
|
def process_manual_failover_from_leader(self):
|
||||||
|
failover = self.cluster.failover
|
||||||
|
if not failover.leader or failover.leader == self.state_handler.name:
|
||||||
|
if not failover.member or failover.member != self.state_handler.name:
|
||||||
|
members = [m for m in self.cluster.members if not failover.member or m.name == failover.member]
|
||||||
|
if self.is_failover_possible(members): # check that there are healthy members
|
||||||
|
self._async_executor.schedule('manual failover: demote')
|
||||||
|
self._async_executor.run_async(self.demote)
|
||||||
|
return 'manual failover: demoting myself'
|
||||||
|
else:
|
||||||
|
logger.warning('manual failover: no healthy members found, failover is not possible')
|
||||||
|
else:
|
||||||
|
logger.warning('manual failover: I am already the leader, no need to failover')
|
||||||
|
else:
|
||||||
|
logger.warning('manual failover: leader name does not match: %s != %s',
|
||||||
|
self.cluster.failover.leader, self.state_handler.name)
|
||||||
|
|
||||||
|
logger.info('Trying to clean up failover key')
|
||||||
|
self.dcs.manual_failover('', '', self.cluster.failover.index)
|
||||||
|
|
||||||
|
def process_unhealthy_cluster(self):
|
||||||
|
if self.is_healthiest_node():
|
||||||
|
if self.acquire_lock():
|
||||||
|
if self.cluster.failover:
|
||||||
|
logger.info('Cleaning up failover key after acquiring leader lock...')
|
||||||
|
self.dcs.manual_failover('', '')
|
||||||
|
self.dcs.get_cluster()
|
||||||
|
return self.enforce_master_role('acquired session lock as a leader',
|
||||||
|
'promoted self to leader by acquiring session lock')
|
||||||
|
else:
|
||||||
|
return self.follow_the_leader('demoted self due after trying and failing to obtain lock',
|
||||||
|
'following new leader after trying and failing to obtain lock')
|
||||||
|
else:
|
||||||
|
if self.patroni.nofailover:
|
||||||
|
return self.follow_the_leader('demoting self because I am not allowed to become master',
|
||||||
|
'following a different leader because I am not allowed to promote')
|
||||||
|
return self.follow_the_leader('demoting self because i am not the healthiest node',
|
||||||
|
'following a different leader because i am not the healthiest node')
|
||||||
|
|
||||||
|
def process_healthy_cluster(self):
|
||||||
|
if self.has_lock():
|
||||||
|
if self.cluster.failover:
|
||||||
|
msg = self.process_manual_failover_from_leader()
|
||||||
|
if msg is not None:
|
||||||
|
return msg
|
||||||
|
|
||||||
|
if self.update_lock():
|
||||||
|
return self.enforce_master_role('no action. i am the leader with the lock',
|
||||||
|
'promoted self to leader because i had the session lock')
|
||||||
|
else:
|
||||||
|
# Either there is no connection to DCS or someone else acquired the lock
|
||||||
|
logger.error('failed to update leader lock')
|
||||||
|
self.load_cluster_from_dcs()
|
||||||
|
else:
|
||||||
|
logger.info('does not have lock')
|
||||||
|
return self.follow_the_leader('demoting self because i do not have the lock and i was a leader',
|
||||||
|
'no action. i am a secondary and i am following a leader', False)
|
||||||
|
|
||||||
|
def schedule(self, action):
|
||||||
|
with self._async_executor:
|
||||||
|
return self._async_executor.schedule(action)
|
||||||
|
|
||||||
|
def restart_scheduled(self):
|
||||||
|
return self._async_executor.scheduled_action == 'restart'
|
||||||
|
|
||||||
|
def schedule_reinitialize(self):
|
||||||
|
return self.schedule('reinitialize')
|
||||||
|
|
||||||
|
def reinitialize_scheduled(self):
|
||||||
|
return self._async_executor.scheduled_action == 'reinitialize'
|
||||||
|
|
||||||
|
def restart(self):
|
||||||
|
with self._async_executor:
|
||||||
|
prev = self._async_executor.schedule('restart', True)
|
||||||
|
if prev is not None:
|
||||||
|
return (False, prev + ' already in progress')
|
||||||
|
if self._async_executor.run(self.state_handler.restart):
|
||||||
|
return (True, 'restarted successfully')
|
||||||
|
else:
|
||||||
|
return (False, 'restart failed')
|
||||||
|
|
||||||
|
def reinitialize(self, cluster):
|
||||||
|
self.state_handler.stop('immediate')
|
||||||
|
self.state_handler.remove_data_directory()
|
||||||
|
self.copy_backup_from_leader(cluster.leader)
|
||||||
|
|
||||||
|
def process_scheduled_action(self):
|
||||||
|
if self.reinitialize_scheduled():
|
||||||
|
if self.cluster.is_unlocked():
|
||||||
|
logger.error('Cluster has no leader, can not reinitialize')
|
||||||
|
self._async_executor.reset_scheduled_action()
|
||||||
|
elif self.has_lock():
|
||||||
|
logger.error('I am the leader, can not reinitialize')
|
||||||
|
self._async_executor.reset_scheduled_action()
|
||||||
|
else:
|
||||||
|
self._async_executor.run_async(self.reinitialize, args=(self.cluster, ))
|
||||||
|
return 'reinitialize started'
|
||||||
|
|
||||||
|
def handle_long_action_in_progress(self):
|
||||||
|
if self.has_lock():
|
||||||
|
if self.update_lock():
|
||||||
|
return 'updated leader lock during ' + self._async_executor.scheduled_action
|
||||||
|
else:
|
||||||
|
return 'failed to update leader lock during ' + self._async_executor.scheduled_action
|
||||||
|
elif self.cluster.is_unlocked():
|
||||||
|
return 'not healthy enough for leader race'
|
||||||
|
else:
|
||||||
|
return self._async_executor.scheduled_action + ' in progress'
|
||||||
|
|
||||||
|
def sysid_valid(self, sysid):
|
||||||
|
# sysid does tv_sec << 32, where tv_sec is the number of seconds sine 1970,
|
||||||
|
# so even 1 << 32 would have 10 digits.
|
||||||
|
return str(sysid) and len(str(sysid)) >= 10 and str(sysid).isdigit()
|
||||||
|
|
||||||
|
def _run_cycle(self):
|
||||||
try:
|
try:
|
||||||
self.load_cluster_from_dcs()
|
self.load_cluster_from_dcs()
|
||||||
if not self.state_handler.is_healthy():
|
|
||||||
has_lock = self.has_lock()
|
|
||||||
self.state_handler.write_recovery_conf(None if has_lock else self.cluster.leader)
|
|
||||||
self.state_handler.start()
|
|
||||||
if not has_lock:
|
|
||||||
return 'started as a secondary'
|
|
||||||
logger.info('started as readonly because i had the session lock')
|
|
||||||
self.load_cluster_from_dcs()
|
|
||||||
|
|
||||||
if self.cluster.is_unlocked():
|
self.touch_member()
|
||||||
if self.state_handler.is_healthiest_node(self.old_cluster):
|
|
||||||
if self.acquire_lock():
|
# cluster has leader key but not initialize key
|
||||||
if self.state_handler.is_leader() or self.state_handler.is_promoted:
|
if not self.cluster.is_unlocked() and not self.sysid_valid(self.cluster.initialize) and self.has_lock():
|
||||||
return 'acquired session lock as a leader'
|
self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
|
||||||
else:
|
|
||||||
self.state_handler.promote()
|
if self._async_executor.busy:
|
||||||
return 'promoted self to leader by acquiring session lock'
|
return self.handle_long_action_in_progress()
|
||||||
else:
|
|
||||||
self.load_cluster_from_dcs()
|
# currently it can trigger only reinitialize
|
||||||
if self.state_handler.is_leader():
|
msg = self.process_scheduled_action()
|
||||||
self.demote()
|
if msg is not None:
|
||||||
return 'demoted self due after trying and failing to obtain lock'
|
return msg
|
||||||
else:
|
|
||||||
self.follow_the_leader()
|
# is data directory empty?
|
||||||
return 'following new leader after trying and failing to obtain lock'
|
if self.state_handler.data_directory_empty():
|
||||||
else:
|
return self.bootstrap() # new node
|
||||||
self.load_cluster_from_dcs()
|
# "bootstrap", but data directory is not empty
|
||||||
if self.state_handler.is_leader():
|
elif not self.sysid_valid(self.cluster.initialize) and self.cluster.is_unlocked():
|
||||||
self.demote()
|
self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
|
||||||
return 'demoting self because i am not the healthiest node'
|
|
||||||
else:
|
|
||||||
self.follow_the_leader()
|
|
||||||
return 'following a different leader because i am not the healthiest node'
|
|
||||||
else:
|
else:
|
||||||
if self.has_lock() and self.update_lock():
|
# check if we are allowed to join
|
||||||
if self.state_handler.is_leader() or self.state_handler.is_promoted:
|
if self.sysid_valid(self.cluster.initialize) and self.cluster.initialize != self.state_handler.sysid:
|
||||||
return 'no action. i am the leader with the lock'
|
logger.fatal("system ID mismatch, node {0} belongs to a different cluster".
|
||||||
else:
|
format(self.state_handler.name))
|
||||||
self.state_handler.promote()
|
sys.exit(1)
|
||||||
return 'promoted self to leader because i had the session lock'
|
|
||||||
|
# try to start dead postgres
|
||||||
|
if not self.state_handler.is_healthy():
|
||||||
|
msg = self.recover()
|
||||||
|
if msg is not None:
|
||||||
|
return msg
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.cluster.is_unlocked():
|
||||||
|
return self.process_unhealthy_cluster()
|
||||||
else:
|
else:
|
||||||
logger.info('does not have lock')
|
return self.process_healthy_cluster()
|
||||||
if self.state_handler.is_leader():
|
finally:
|
||||||
self.demote()
|
self.state_handler.sync_replication_slots(self.cluster)
|
||||||
return 'demoting self because i do not have the lock and i was a leader'
|
|
||||||
else:
|
|
||||||
self.follow_the_leader()
|
|
||||||
return 'no action. i am a secondary and i am following a leader'
|
|
||||||
except DCSError:
|
except DCSError:
|
||||||
logger.error('Error communicating with DCS')
|
logger.error('Error communicating with DCS')
|
||||||
if self.state_handler.is_leader():
|
if self.state_handler.is_running() and self.state_handler.is_leader():
|
||||||
self.state_handler.demote(None)
|
self.demote(delete_leader=False)
|
||||||
return 'demoted self because DCS is not accessible and i was a leader'
|
return 'demoted self because DCS is not accessible and i was a leader'
|
||||||
except (InterfaceError, OperationalError):
|
except (psycopg2.Error, PostgresConnectionException):
|
||||||
logger.error('Error communicating with Postgresql. Will try again')
|
logger.exception('Error communicating with Postgresql. Will try again later')
|
||||||
|
|
||||||
|
def run_cycle(self):
|
||||||
|
with self._async_executor:
|
||||||
|
return self._run_cycle()
|
||||||
|
|||||||
+514
-176
@@ -4,9 +4,14 @@ import psycopg2
|
|||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
from patroni.utils import sleep
|
from patroni.exceptions import PostgresConnectionException, PostgresException
|
||||||
|
from patroni.utils import Retry, RetryFailedError
|
||||||
|
from six import string_types
|
||||||
from six.moves.urllib_parse import urlparse
|
from six.moves.urllib_parse import urlparse
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -39,21 +44,25 @@ class Postgresql:
|
|||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.name = config['name']
|
self.name = config['name']
|
||||||
|
self.server_parameters = config.get('parameters', {})
|
||||||
self.scope = config['scope']
|
self.scope = config['scope']
|
||||||
self.listen_addresses, self.port = config['listen'].split(':')
|
self.listen_addresses, self.port = config['listen'].split(':')
|
||||||
self.data_dir = config['data_dir']
|
self.data_dir = config['data_dir']
|
||||||
self.replication = config['replication']
|
self.replication = config['replication']
|
||||||
self.superuser = config['superuser']
|
self.superuser = config['superuser']
|
||||||
self.admin = config['admin']
|
self.admin = config['admin']
|
||||||
|
self.initdb_options = config.get('initdb', [])
|
||||||
|
self.pgpass = config.get('pgpass', None) or os.path.join(os.path.expanduser('~'), 'pgpass')
|
||||||
|
self.pg_rewind = config.get('pg_rewind', {})
|
||||||
self.callback = config.get('callbacks', {})
|
self.callback = config.get('callbacks', {})
|
||||||
self.use_slots = config.get('use_slots', True)
|
self.use_slots = config.get('use_slots', True)
|
||||||
|
self.schedule_load_slots = self.use_slots
|
||||||
self.recovery_conf = os.path.join(self.data_dir, 'recovery.conf')
|
self.recovery_conf = os.path.join(self.data_dir, 'recovery.conf')
|
||||||
self.configuration_to_save = (os.path.join(self.data_dir, 'pg_hba.conf'),
|
self.configuration_to_save = (os.path.join(self.data_dir, 'pg_hba.conf'),
|
||||||
os.path.join(self.data_dir, 'postgresql.conf'))
|
os.path.join(self.data_dir, 'postgresql.conf'))
|
||||||
self.postmaster_pid = os.path.join(self.data_dir, 'postmaster.pid')
|
self.postmaster_pid = os.path.join(self.data_dir, 'postmaster.pid')
|
||||||
self.trigger_file = config.get('recovery_conf', {}).get('trigger_file', None) or 'promote'
|
self.trigger_file = config.get('recovery_conf', {}).get('trigger_file', None) or 'promote'
|
||||||
self.trigger_file = os.path.abspath(os.path.join(self.data_dir, self.trigger_file))
|
self.trigger_file = os.path.abspath(os.path.join(self.data_dir, self.trigger_file))
|
||||||
self.is_promoted = False
|
|
||||||
|
|
||||||
self._pg_ctl = ['pg_ctl', '-w', '-D', self.data_dir]
|
self._pg_ctl = ['pg_ctl', '-w', '-D', self.data_dir]
|
||||||
|
|
||||||
@@ -64,7 +73,50 @@ class Postgresql:
|
|||||||
|
|
||||||
self._connection = None
|
self._connection = None
|
||||||
self._cursor_holder = None
|
self._cursor_holder = None
|
||||||
self.members = [] # list of already existing replication slots
|
self._need_rewind = False
|
||||||
|
self._sysid = None
|
||||||
|
self.replication_slots = [] # list of already existing replication slots
|
||||||
|
self.retry = Retry(max_tries=-1, deadline=5, max_delay=1, retry_exceptions=PostgresConnectionException)
|
||||||
|
|
||||||
|
self._state = 'stopped'
|
||||||
|
self._state_lock = Lock()
|
||||||
|
self._role = 'replica'
|
||||||
|
self._role_lock = Lock()
|
||||||
|
|
||||||
|
if self.is_running():
|
||||||
|
self._state = 'running'
|
||||||
|
self._role = 'master' if self.is_leader() else 'replica'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_rewind(self):
|
||||||
|
""" check if pg_rewind executable is there and that pg_controldata indicates
|
||||||
|
we have either wal_log_hints or checksums turned on
|
||||||
|
"""
|
||||||
|
# low-hanging fruit: check if pg_rewind configuration is there
|
||||||
|
if not self.pg_rewind or\
|
||||||
|
not (self.pg_rewind.get('username', '') and self.pg_rewind.get('password', '')):
|
||||||
|
return False
|
||||||
|
|
||||||
|
cmd = ['pg_rewind', '--help']
|
||||||
|
try:
|
||||||
|
ret = subprocess.call(cmd, stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT)
|
||||||
|
if ret != 0: # pg_rewind is not there, close up the shop and go home
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
# check if the cluster's configuration permits pg_rewind
|
||||||
|
data = self.controldata()
|
||||||
|
return data.get('wal_log_hints setting', 'off') == 'on' or data.get('Data page checksum version', '0') != '0'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sysid(self):
|
||||||
|
if not self._sysid:
|
||||||
|
data = self.controldata()
|
||||||
|
self._sysid = data.get('Database system identifier', "")
|
||||||
|
return self._sysid
|
||||||
|
|
||||||
|
def require_rewind(self):
|
||||||
|
self._need_rewind = True
|
||||||
|
|
||||||
def get_local_address(self):
|
def get_local_address(self):
|
||||||
listen_addresses = self.listen_addresses.split(',')
|
listen_addresses = self.listen_addresses.split(',')
|
||||||
@@ -84,150 +136,267 @@ class Postgresql:
|
|||||||
return self._connection
|
return self._connection
|
||||||
|
|
||||||
def _cursor(self):
|
def _cursor(self):
|
||||||
if not self._cursor_holder or self._cursor_holder.closed:
|
if not self._cursor_holder or self._cursor_holder.closed or self._cursor_holder.connection.closed != 0:
|
||||||
|
logger.info("established a new patroni connection to the postgres cluster")
|
||||||
self._cursor_holder = self.connection().cursor()
|
self._cursor_holder = self.connection().cursor()
|
||||||
return self._cursor_holder
|
return self._cursor_holder
|
||||||
|
|
||||||
def disconnect(self):
|
def close_connection(self):
|
||||||
self._connection and self._connection.close()
|
if self._cursor_holder and self._cursor_holder.connection and self._cursor_holder.connection.closed == 0:
|
||||||
self._connection = self._cursor_holder = None
|
self._cursor_holder.connection.close()
|
||||||
|
logger.info("closed patroni connection to the postgresql cluster")
|
||||||
|
|
||||||
|
def _query(self, sql, *params):
|
||||||
|
cursor = None
|
||||||
|
try:
|
||||||
|
cursor = self._cursor()
|
||||||
|
cursor.execute(sql, params)
|
||||||
|
return cursor
|
||||||
|
except psycopg2.Error as e:
|
||||||
|
if cursor and cursor.connection.closed == 0:
|
||||||
|
raise e
|
||||||
|
if self.state == 'restarting':
|
||||||
|
raise RetryFailedError('cluster is being restarted')
|
||||||
|
raise PostgresConnectionException('connection problems')
|
||||||
|
|
||||||
def query(self, sql, *params):
|
def query(self, sql, *params):
|
||||||
max_attempts = 0
|
try:
|
||||||
while True:
|
return self.retry(self._query, sql, *params)
|
||||||
ex = None
|
except RetryFailedError as e:
|
||||||
try:
|
raise PostgresConnectionException(str(e))
|
||||||
cursor = self._cursor()
|
|
||||||
cursor.execute(sql, params)
|
|
||||||
return cursor
|
|
||||||
except psycopg2.InterfaceError as e:
|
|
||||||
ex = e
|
|
||||||
except psycopg2.OperationalError as e:
|
|
||||||
if self._connection and self._connection.closed == 0:
|
|
||||||
raise e
|
|
||||||
ex = e
|
|
||||||
if ex:
|
|
||||||
self.disconnect()
|
|
||||||
max_attempts += 1
|
|
||||||
if max_attempts >= 3:
|
|
||||||
raise ex
|
|
||||||
sleep(5)
|
|
||||||
|
|
||||||
def data_directory_empty(self):
|
def data_directory_empty(self):
|
||||||
return not os.path.exists(self.data_dir) or os.listdir(self.data_dir) == []
|
return not os.path.exists(self.data_dir) or os.listdir(self.data_dir) == []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def initdb_allowed_option(name):
|
||||||
|
if name in ['pgdata', 'nosync', 'pwfile', 'sync-only']:
|
||||||
|
raise Exception('{} option for initdb is not allowed'.format(name))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_initdb_options(self):
|
||||||
|
options = []
|
||||||
|
for o in self.initdb_options:
|
||||||
|
if isinstance(o, string_types) and self.initdb_allowed_option(o):
|
||||||
|
options.append('--{}'.format(o))
|
||||||
|
elif isinstance(o, dict):
|
||||||
|
keys = list(o.keys())
|
||||||
|
if len(keys) != 1 or not isinstance(keys[0], string_types) or not self.initdb_allowed_option(keys[0]):
|
||||||
|
raise Exception('Invalid option: {}'.format(o))
|
||||||
|
options.append('--{}={}'.format(keys[0], o[keys[0]]))
|
||||||
|
else:
|
||||||
|
raise Exception('Unknown type of initdb option: {}'.format(o))
|
||||||
|
return options
|
||||||
|
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
ret = subprocess.call(self._pg_ctl + ['initdb', '-o', '--encoding=UTF8']) == 0
|
self.set_state('initalizing new cluster')
|
||||||
ret and self.write_pg_hba()
|
options = self.get_initdb_options()
|
||||||
|
pwfile = None
|
||||||
|
if self.superuser and 'username' not in self.superuser and 'password' in self.superuser:
|
||||||
|
(fd, pwfile) = tempfile.mkstemp()
|
||||||
|
os.write(fd, self.superuser['password'].encode())
|
||||||
|
os.close(fd)
|
||||||
|
options.append('--pwfile={}'.format(pwfile))
|
||||||
|
|
||||||
|
ret = subprocess.call(self._pg_ctl + ['initdb'] + ['-o', ' '.join(options)] if options else []) == 0
|
||||||
|
if pwfile:
|
||||||
|
os.remove(pwfile)
|
||||||
|
if ret:
|
||||||
|
self.write_pg_hba()
|
||||||
|
else:
|
||||||
|
self.set_state('initdb failed')
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def delete_trigger_file(self):
|
def delete_trigger_file(self):
|
||||||
os.path.exists(self.trigger_file) and os.unlink(self.trigger_file)
|
os.path.exists(self.trigger_file) and os.unlink(self.trigger_file)
|
||||||
|
|
||||||
|
def write_pgpass(self, record):
|
||||||
|
with open(self.pgpass, 'w') as f:
|
||||||
|
os.fchmod(f.fileno(), 0o600)
|
||||||
|
f.write('{host}:{port}:*:{user}:{password}\n'.format(**record))
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['PGPASSFILE'] = self.pgpass
|
||||||
|
return env
|
||||||
|
|
||||||
def sync_from_leader(self, leader):
|
def sync_from_leader(self, leader):
|
||||||
r = parseurl(leader.conn_url)
|
r = parseurl(leader.conn_url)
|
||||||
|
|
||||||
pgpass = 'pgpass'
|
env = self.write_pgpass(r)
|
||||||
with open(pgpass, 'w') as f:
|
ret = self.create_replica(leader, env) == 0
|
||||||
os.fchmod(f.fileno(), 0o600)
|
ret and self.delete_trigger_file()
|
||||||
f.write('{host}:{port}:*:{user}:{password}\n'.format(**r))
|
return ret
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
env['PGPASSFILE'] = pgpass
|
|
||||||
return self.create_replica(r, env) == 0
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_connstring(conn):
|
def build_connstring(conn):
|
||||||
return "host={host} port={port} user={user}".format(**conn)
|
"""
|
||||||
|
>>> Postgresql.build_connstring({'host': '127.0.0.1', 'port': '5432'}) == 'host=127.0.0.1 port=5432'
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
return ' '.join('{}={}'.format(param, val) for param, val in sorted(conn.items()))
|
||||||
|
|
||||||
|
def create_replica(self, leader, env):
|
||||||
|
# create the replica according to the replica_method
|
||||||
|
# defined by the user. this is a list, so we need to
|
||||||
|
# loop through all methods the user supplies
|
||||||
|
connstring = leader.conn_url
|
||||||
|
# get list of replica methods from config.
|
||||||
|
# If there is no configuration key, or no value is specified, use basebackup
|
||||||
|
replica_methods = self.config.get('create_replica_method') or ['basebackup']
|
||||||
|
# go through them in priority order
|
||||||
|
ret = 1
|
||||||
|
for replica_method in replica_methods:
|
||||||
|
# if the method is basebackup, then use the built-in
|
||||||
|
if replica_method == "basebackup":
|
||||||
|
ret = self.basebackup(leader, env)
|
||||||
|
if ret == 0:
|
||||||
|
logger.info("replica has been created using basebackup")
|
||||||
|
# if basebackup succeeds, exit with success
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
cmd = replica_method
|
||||||
|
method_config = {}
|
||||||
|
# user-defined method; check for configuration
|
||||||
|
# not required, actually
|
||||||
|
if replica_method in self.config:
|
||||||
|
method_config = self.config[replica_method].copy()
|
||||||
|
# look to see if the user has supplied a full command path
|
||||||
|
# if not, use the method name as the command
|
||||||
|
cmd = method_config.pop('command', cmd)
|
||||||
|
# add the default parameters
|
||||||
|
try:
|
||||||
|
method_config.update({"scope": self.scope,
|
||||||
|
"role": "replica",
|
||||||
|
"datadir": self.data_dir,
|
||||||
|
"connstring": connstring})
|
||||||
|
params = ["--{0}={1}".format(arg, val) for arg, val in method_config.items()]
|
||||||
|
# call script with the full set of parameters
|
||||||
|
ret = subprocess.call(shlex.split(cmd) + params, env=env)
|
||||||
|
# if we succeeded, stop
|
||||||
|
if ret == 0:
|
||||||
|
logger.info("replica has been created using {0}".format(replica_method))
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error creating replica using method {0}: {1}'.format(replica_method, str(e)))
|
||||||
|
ret = 1
|
||||||
|
|
||||||
def create_replica(self, master_connection, env):
|
|
||||||
connstring = self.build_connstring(master_connection)
|
|
||||||
cmd = self.config['restore']
|
|
||||||
try:
|
|
||||||
ret = subprocess.call(shlex.split(cmd) + [self.scope, "replica", self.data_dir, connstring], env=env)
|
|
||||||
self.delete_trigger_file()
|
|
||||||
except:
|
|
||||||
logger.exception('Error when creating replica')
|
|
||||||
return 1
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def is_leader(self, check_only=False):
|
def is_leader(self):
|
||||||
ret = not self.query('SELECT pg_is_in_recovery()').fetchone()[0]
|
return not self.query('SELECT pg_is_in_recovery()').fetchone()[0]
|
||||||
if ret and self.is_promoted and not check_only:
|
|
||||||
self.delete_trigger_file()
|
|
||||||
self.is_promoted = False
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def is_running(self):
|
def is_running(self):
|
||||||
return subprocess.call(' '.join(self._pg_ctl) + ' status > /dev/null', shell=True) == 0
|
return subprocess.call(' '.join(self._pg_ctl) + ' status > /dev/null 2>&1', shell=True) == 0
|
||||||
|
|
||||||
def call_nowait(self, cb_name, is_leader=None):
|
def call_nowait(self, cb_name):
|
||||||
""" pick a callback command and call it without waiting for it to finish """
|
""" pick a callback command and call it without waiting for it to finish """
|
||||||
if not self.callback or cb_name not in self.callback:
|
if not self.callback or cb_name not in self.callback:
|
||||||
return False
|
return False
|
||||||
cmd = self.callback[cb_name]
|
cmd = self.callback[cb_name]
|
||||||
if is_leader is None:
|
|
||||||
try:
|
|
||||||
is_leader = self.is_leader(check_only=True)
|
|
||||||
except psycopg2.OperationalError as e:
|
|
||||||
logger.warning("unable to perform {0} action, cannot obtain the cluster role: {1}".format(cb_name, e))
|
|
||||||
return False
|
|
||||||
try:
|
try:
|
||||||
role = "master" if is_leader else "replica"
|
subprocess.Popen(shlex.split(cmd) + [cb_name, self.role, self.scope])
|
||||||
subprocess.Popen(shlex.split(cmd) + [cb_name, role, self.scope])
|
|
||||||
except:
|
except:
|
||||||
logger.exception('callback %s %s %s %s failed', cmd, cb_name, role, self.scope)
|
logger.exception('callback %s %s %s %s failed', cmd, cb_name, self.role, self.scope)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def start(self):
|
@property
|
||||||
if self.is_running():
|
def role(self):
|
||||||
self.load_replication_slots()
|
with self._role_lock:
|
||||||
logger.error('Cannot start PostgreSQL because one is already running.')
|
return self._role
|
||||||
return False
|
|
||||||
|
|
||||||
|
def set_role(self, value):
|
||||||
|
with self._role_lock:
|
||||||
|
self._role = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self):
|
||||||
|
with self._state_lock:
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
def set_state(self, value):
|
||||||
|
with self._state_lock:
|
||||||
|
self._state = value
|
||||||
|
|
||||||
|
def start(self, block_callbacks=False):
|
||||||
|
if self.is_running():
|
||||||
|
logger.error('Cannot start PostgreSQL because one is already running.')
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.set_role('replica' if os.path.exists(self.recovery_conf) else 'master')
|
||||||
if os.path.exists(self.postmaster_pid):
|
if os.path.exists(self.postmaster_pid):
|
||||||
os.remove(self.postmaster_pid)
|
os.remove(self.postmaster_pid)
|
||||||
logger.info('Removed %s', self.postmaster_pid)
|
logger.info('Removed %s', self.postmaster_pid)
|
||||||
|
|
||||||
|
if not block_callbacks:
|
||||||
|
self.set_state('starting')
|
||||||
|
|
||||||
ret = subprocess.call(self._pg_ctl + ['start', '-o', self.server_options()]) == 0
|
ret = subprocess.call(self._pg_ctl + ['start', '-o', self.server_options()]) == 0
|
||||||
ret and self.load_replication_slots()
|
|
||||||
|
self.set_state('running' if ret else 'start failed')
|
||||||
|
|
||||||
|
self.schedule_load_slots = ret and self.use_slots
|
||||||
self.save_configuration_files()
|
self.save_configuration_files()
|
||||||
if ret and ACTION_ON_START in self.callback:
|
# block_callbacks is used during restart to avoid
|
||||||
self.call_nowait(ACTION_ON_START)
|
# running start/stop callbacks in addition to restart ones
|
||||||
|
ret and not block_callbacks and self.call_nowait(ACTION_ON_START)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def stop(self):
|
def checkpoint(self, connstring=None):
|
||||||
try:
|
try:
|
||||||
is_leader = self.is_leader(check_only=True)
|
connstring = connstring or 'postgres://{}/postgres'.format(self.local_address)
|
||||||
|
with psycopg2.connect(connstring) as conn:
|
||||||
|
conn.autocommit = True
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SET statement_timeout = 0")
|
||||||
|
cur.execute('CHECKPOINT')
|
||||||
except:
|
except:
|
||||||
is_leader = None
|
logging.exception('Exception during CHECKPOINT')
|
||||||
pass
|
|
||||||
ret = subprocess.call(self._pg_ctl + ['stop', '-m', 'fast'])
|
def stop(self, mode='fast', block_callbacks=False):
|
||||||
if ret == 0 and ACTION_ON_STOP in self.callback:
|
# make sure we close all connections established against
|
||||||
self.call_nowait(ACTION_ON_STOP, is_leader=is_leader)
|
# the former node, otherwise, we might get a stalled one
|
||||||
return ret == 0
|
# after kill -9, which would report incorrect data to
|
||||||
|
# patroni.
|
||||||
|
|
||||||
|
self.close_connection()
|
||||||
|
if not self.is_running():
|
||||||
|
if not block_callbacks:
|
||||||
|
self.set_state('stopped')
|
||||||
|
return True
|
||||||
|
|
||||||
|
if block_callbacks:
|
||||||
|
self.checkpoint()
|
||||||
|
else:
|
||||||
|
self.set_state('stopping')
|
||||||
|
|
||||||
|
ret = subprocess.call(self._pg_ctl + ['stop', '-m', mode]) == 0
|
||||||
|
# block_callbacks is used during restart to avoid
|
||||||
|
# running start/stop callbacks in addition to restart ones
|
||||||
|
if not ret:
|
||||||
|
self.set_state('stop failed')
|
||||||
|
elif not block_callbacks:
|
||||||
|
self.set_state('stopped')
|
||||||
|
self.call_nowait(ACTION_ON_STOP)
|
||||||
|
return ret
|
||||||
|
|
||||||
def reload(self):
|
def reload(self):
|
||||||
ret = subprocess.call(self._pg_ctl + ['reload'])
|
ret = subprocess.call(self._pg_ctl + ['reload']) == 0
|
||||||
if ret == 0 and ACTION_ON_RELOAD in self.callback:
|
ret and self.call_nowait(ACTION_ON_RELOAD)
|
||||||
self.call_nowait(ACTION_ON_RELOAD)
|
return ret
|
||||||
return ret == 0
|
|
||||||
|
|
||||||
def restart(self):
|
def restart(self):
|
||||||
try:
|
self.set_state('restarting')
|
||||||
is_leader = self.is_leader(check_only=True)
|
ret = self.stop(block_callbacks=True) and self.start(block_callbacks=True)
|
||||||
except:
|
if ret:
|
||||||
is_leader = None
|
self.call_nowait(ACTION_ON_RESTART)
|
||||||
pass
|
else:
|
||||||
ret = subprocess.call(self._pg_ctl + ['restart', '-m', 'fast'])
|
self.set_state('restart failed ({})'.format(self.state))
|
||||||
if ret == 0 and ACTION_ON_RESTART in self.callback:
|
return ret
|
||||||
self.call_nowait(ACTION_ON_RESTART, is_leader=is_leader)
|
|
||||||
return ret == 0
|
|
||||||
|
|
||||||
def server_options(self):
|
def server_options(self):
|
||||||
options = "--listen_addresses='{}' --port={}".format(self.listen_addresses, self.port)
|
options = "--listen_addresses='{}' --port={}".format(self.listen_addresses, self.port)
|
||||||
for setting, value in self.config['parameters'].items():
|
for setting, value in self.server_parameters.items():
|
||||||
options += " --{}='{}'".format(setting, value)
|
options += " --{}='{}'".format(setting, value)
|
||||||
return options
|
return options
|
||||||
|
|
||||||
@@ -237,42 +406,15 @@ class Postgresql:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def is_healthiest_node(self, cluster):
|
def check_replication_lag(self, last_leader_operation):
|
||||||
if self.is_leader():
|
return (last_leader_operation if last_leader_operation else 0) - self.xlog_position() <=\
|
||||||
return True
|
self.config.get('maximum_lag_on_failover', 0)
|
||||||
|
|
||||||
if cluster.last_leader_operation - self.xlog_position() > self.config.get('maximum_lag_on_failover', 0):
|
|
||||||
return False
|
|
||||||
|
|
||||||
for member in cluster.members:
|
|
||||||
if member.name == self.name:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
r = parseurl(member.conn_url)
|
|
||||||
member_conn = psycopg2.connect(**r)
|
|
||||||
member_conn.autocommit = True
|
|
||||||
member_cursor = member_conn.cursor()
|
|
||||||
member_cursor.execute(
|
|
||||||
"SELECT pg_is_in_recovery(), %s - pg_xlog_location_diff(pg_last_xlog_replay_location(), '0/0')",
|
|
||||||
(self.xlog_position(),))
|
|
||||||
row = member_cursor.fetchone()
|
|
||||||
member_cursor.close()
|
|
||||||
member_conn.close()
|
|
||||||
logger.error([self.name, member.name, row])
|
|
||||||
if not row[0]:
|
|
||||||
logger.warning('Master (%s) is still alive', member.name)
|
|
||||||
return False
|
|
||||||
if row[1] < 0:
|
|
||||||
return False
|
|
||||||
except psycopg2.Error:
|
|
||||||
continue
|
|
||||||
return True
|
|
||||||
|
|
||||||
def write_pg_hba(self):
|
def write_pg_hba(self):
|
||||||
with open(os.path.join(self.data_dir, 'pg_hba.conf'), 'a') as f:
|
with open(os.path.join(self.data_dir, 'pg_hba.conf'), 'a') as f:
|
||||||
f.write('\nhost replication {username} {network} md5\n'.format(**self.replication))
|
f.write('\nhost replication {username} {network} md5\n'.format(**self.replication))
|
||||||
for line in self.config.get('pg_hba', []):
|
for line in self.config.get('pg_hba', []):
|
||||||
if line.split()[0].strip() == 'hostssl' and self.config['parameters'].get('ssl', 'off').lower() != 'on':
|
if line.split()[0].strip() == 'hostssl' and self.server_parameters.get('ssl', 'off').lower() != 'on':
|
||||||
continue
|
continue
|
||||||
f.write(line + '\n')
|
f.write(line + '\n')
|
||||||
|
|
||||||
@@ -290,10 +432,7 @@ class Postgresql:
|
|||||||
with open(self.recovery_conf, 'r') as f:
|
with open(self.recovery_conf, 'r') as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
if line.startswith('primary_conninfo'):
|
if line.startswith('primary_conninfo'):
|
||||||
if not pattern:
|
return pattern and (pattern in line)
|
||||||
return False
|
|
||||||
return pattern in line
|
|
||||||
|
|
||||||
return not pattern
|
return not pattern
|
||||||
|
|
||||||
def write_recovery_conf(self, leader):
|
def write_recovery_conf(self, leader):
|
||||||
@@ -308,86 +447,285 @@ recovery_target_timeline = 'latest'
|
|||||||
for name, value in self.config.get('recovery_conf', {}).items():
|
for name, value in self.config.get('recovery_conf', {}).items():
|
||||||
f.write("{} = '{}'\n".format(name, value))
|
f.write("{} = '{}'\n".format(name, value))
|
||||||
|
|
||||||
def follow_the_leader(self, leader):
|
def rewind(self, leader):
|
||||||
if not self.check_recovery_conf(leader):
|
# prepare pg_rewind connection
|
||||||
|
r = parseurl(leader.conn_url)
|
||||||
|
r.update(self.pg_rewind)
|
||||||
|
r['user'] = r['username']
|
||||||
|
env = self.write_pgpass(r)
|
||||||
|
pc = "user={user} host={host} port={port} dbname=postgres sslmode=prefer sslcompression=1".format(**r)
|
||||||
|
# first run a checkpoint on a promoted master in order
|
||||||
|
# to make it store the new timeline ([email protected])
|
||||||
|
self.checkpoint(pc)
|
||||||
|
logger.info("running pg_rewind from {}".format(pc))
|
||||||
|
pg_rewind = ['pg_rewind', '-D', self.data_dir, '--source-server', pc]
|
||||||
|
try:
|
||||||
|
ret = (subprocess.call(pg_rewind, env=env) == 0)
|
||||||
|
except:
|
||||||
|
ret = False
|
||||||
|
if ret:
|
||||||
self.write_recovery_conf(leader)
|
self.write_recovery_conf(leader)
|
||||||
self.restart()
|
return ret
|
||||||
if ACTION_ON_ROLE_CHANGE in self.callback:
|
|
||||||
self.call_nowait(ACTION_ON_ROLE_CHANGE)
|
def controldata(self):
|
||||||
|
""" return the contents of pg_controldata, or non-True value if pg_controldata call failed """
|
||||||
|
result = {}
|
||||||
|
try:
|
||||||
|
data = subprocess.check_output(['pg_controldata', self.data_dir])
|
||||||
|
if data:
|
||||||
|
data = data.decode().splitlines()
|
||||||
|
result = {l.split(':')[0].replace('Current ', '', 1): l.split(':')[1].strip() for l in data if l}
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
logger.exception("Error when calling pg_controldata")
|
||||||
|
finally:
|
||||||
|
return result
|
||||||
|
|
||||||
|
def read_postmaster_opts(self):
|
||||||
|
""" returns the list of option names/values from postgres.opts, Empty dict if read failed or no file """
|
||||||
|
result = {}
|
||||||
|
try:
|
||||||
|
with open(os.path.join(self.data_dir, "postmaster.opts")) as f:
|
||||||
|
data = f.read()
|
||||||
|
opts = [opt.strip('"\n') for opt in data.split(' "')]
|
||||||
|
for opt in opts:
|
||||||
|
if '=' in opt and opt.startswith('--'):
|
||||||
|
name, val = opt.split('=', 1)
|
||||||
|
name = name.strip('-')
|
||||||
|
result[name] = val
|
||||||
|
except IOError:
|
||||||
|
logger.exception('Error when reading postmaster.opts')
|
||||||
|
finally:
|
||||||
|
return result
|
||||||
|
|
||||||
|
def single_user_mode(self, command=None, options={}):
|
||||||
|
""" run a given command in a single-user mode. If the command is empty - then just start and stop """
|
||||||
|
cmd = ['postgres', '--single', '-D', self.data_dir]
|
||||||
|
for opt in sorted(options):
|
||||||
|
cmd.extend(['-c', '{0}={1}'.format(opt, options[opt])])
|
||||||
|
# need a database name to connect
|
||||||
|
cmd.append('postgres')
|
||||||
|
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT)
|
||||||
|
if p:
|
||||||
|
command and p.communicate('{}\n'.format(command))
|
||||||
|
p.stdin.close()
|
||||||
|
return p.wait()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def cleanup_archive_status(self):
|
||||||
|
status_dir = os.path.join(self.data_dir, 'pg_xlog', 'archive_status')
|
||||||
|
if os.path.isdir(status_dir):
|
||||||
|
for f in os.listdir(status_dir):
|
||||||
|
path = os.path.join(status_dir, f)
|
||||||
|
try:
|
||||||
|
if os.path.islink(path):
|
||||||
|
os.unlink(path)
|
||||||
|
elif os.path.isfile(path):
|
||||||
|
os.remove(path)
|
||||||
|
except:
|
||||||
|
logger.exception("Unable to remove {}".format(path))
|
||||||
|
|
||||||
|
def follow_the_leader(self, leader, recovery=False):
|
||||||
|
if not self.check_recovery_conf(leader) or recovery:
|
||||||
|
change_role = (self.role == 'master')
|
||||||
|
|
||||||
|
self._need_rewind = (self._need_rewind or change_role) and self.can_rewind
|
||||||
|
if self._need_rewind:
|
||||||
|
logger.info("set the rewind flag after demote")
|
||||||
|
self.write_recovery_conf(leader)
|
||||||
|
if not leader or not self._need_rewind: # do not rewind until the leader becomes available
|
||||||
|
ret = self.restart()
|
||||||
|
else: # we have a leader and need to rewind
|
||||||
|
if self.is_running():
|
||||||
|
self.stop()
|
||||||
|
# at present, pg_rewind only runs when the cluster is shut down cleanly
|
||||||
|
# and not shutdown in recovery. We have to remove the recovery.conf if present
|
||||||
|
# and start/shutdown in a single user mode to emulate this.
|
||||||
|
# XXX: if recovery.conf is linked, it will be written anew as a normal file.
|
||||||
|
if os.path.islink(self.recovery_conf):
|
||||||
|
os.unlink(self.recovery_conf)
|
||||||
|
else:
|
||||||
|
os.remove(self.recovery_conf)
|
||||||
|
# Archived segments might be useful to pg_rewind,
|
||||||
|
# clean the flags that tell we should remove them.
|
||||||
|
self.cleanup_archive_status()
|
||||||
|
# Start in a single user mode and stop to produce a clean shutdown
|
||||||
|
opts = self.read_postmaster_opts()
|
||||||
|
opts['archive_mode'] = 'on'
|
||||||
|
opts['archive_command'] = 'false'
|
||||||
|
self.single_user_mode(options=opts)
|
||||||
|
if self.rewind(leader):
|
||||||
|
ret = self.start()
|
||||||
|
else:
|
||||||
|
logger.error("unable to rewind the former master")
|
||||||
|
self.remove_data_directory()
|
||||||
|
ret = True
|
||||||
|
self._need_rewind = False
|
||||||
|
change_role and ret and self.call_nowait(ACTION_ON_ROLE_CHANGE)
|
||||||
|
return ret
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
def save_configuration_files(self):
|
def save_configuration_files(self):
|
||||||
"""
|
"""
|
||||||
copy postgresql.conf to postgresql.conf.backup to preserve it in the WAL-e backup.
|
copy postgresql.conf to postgresql.conf.backup to be able to retrive configuration files
|
||||||
see http://comments.gmane.org/gmane.comp.db.postgresql.wal-e/239
|
- originally stored as symlinks, those are normally skipped by pg_basebackup
|
||||||
|
- in case of WAL-E basebackup (see http://comments.gmane.org/gmane.comp.db.postgresql.wal-e/239)
|
||||||
"""
|
"""
|
||||||
for f in self.configuration_to_save:
|
try:
|
||||||
shutil.copy(f, f + '.backup')
|
for f in self.configuration_to_save:
|
||||||
|
os.path.isfile(f) and shutil.copy(f, f + '.backup')
|
||||||
|
except:
|
||||||
|
logger.exception('unable to create backup copies of configuration files')
|
||||||
|
|
||||||
def restore_configuration_files(self):
|
def restore_configuration_files(self):
|
||||||
""" restore a previously saved postgresql.conf """
|
""" restore a previously saved postgresql.conf """
|
||||||
try:
|
try:
|
||||||
for f in self.configuration_to_save:
|
for f in self.configuration_to_save:
|
||||||
shutil.copy(f + '.backup', f)
|
not os.path.isfile(f) and os.path.isfile(f + '.backup') and shutil.copy(f + '.backup', f)
|
||||||
except:
|
except:
|
||||||
logger.exception('unable to restore configuration from WAL-E backup')
|
logger.exception('unable to restore configuration files from backup')
|
||||||
|
|
||||||
def promote(self):
|
def promote(self):
|
||||||
self.is_promoted = subprocess.call(self._pg_ctl + ['promote']) == 0
|
if self.role == 'master':
|
||||||
if self.is_promoted and ACTION_ON_ROLE_CHANGE in self.callback:
|
return True
|
||||||
|
ret = subprocess.call(self._pg_ctl + ['promote']) == 0
|
||||||
|
if ret:
|
||||||
|
self.set_role('master')
|
||||||
|
logger.info("cleared rewind flag after becoming the leader")
|
||||||
|
self._need_rewind = False
|
||||||
self.call_nowait(ACTION_ON_ROLE_CHANGE)
|
self.call_nowait(ACTION_ON_ROLE_CHANGE)
|
||||||
return self.is_promoted
|
return ret
|
||||||
|
|
||||||
def demote(self, leader):
|
def demote(self):
|
||||||
self.follow_the_leader(leader)
|
self.follow_the_leader(None)
|
||||||
|
|
||||||
|
def create_or_update_role(self, name, password, options):
|
||||||
|
self.query("""DO $$
|
||||||
|
BEGIN
|
||||||
|
SET local synchronous_commit = 'local';
|
||||||
|
PERFORM * FROM pg_authid WHERE rolname = %s;
|
||||||
|
IF FOUND THEN
|
||||||
|
ALTER ROLE "{0}" WITH LOGIN {1} PASSWORD %s;
|
||||||
|
ELSE
|
||||||
|
CREATE ROLE "{0}" WITH LOGIN {1} PASSWORD %s;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$""".format(name, options), name, password, password)
|
||||||
|
|
||||||
def create_replication_user(self):
|
def create_replication_user(self):
|
||||||
self.query('CREATE USER "{}" WITH REPLICATION ENCRYPTED PASSWORD %s'.format(
|
self.create_or_update_role(self.replication['username'], self.replication['password'], 'REPLICATION')
|
||||||
self.replication['username']), self.replication['password'])
|
|
||||||
|
|
||||||
def create_connection_users(self):
|
def create_connection_users(self):
|
||||||
if self.superuser:
|
if 'username' in self.superuser:
|
||||||
if 'username' in self.superuser:
|
self.create_or_update_role(self.superuser['username'], self.superuser['password'], 'SUPERUSER')
|
||||||
self.query('CREATE ROLE "{0}" WITH LOGIN SUPERUSER PASSWORD %s'.format(
|
|
||||||
self.superuser['username']), self.superuser['password'])
|
|
||||||
else:
|
|
||||||
rolsuper = self.query("""SELECT rolname FROM pg_authid WHERE rolsuper = 't'""").fetchone()[0]
|
|
||||||
self.query('ALTER ROLE "{0}" WITH PASSWORD %s'.format(rolsuper), self.superuser['password'])
|
|
||||||
if self.admin:
|
if self.admin:
|
||||||
self.query('CREATE ROLE "{0}" WITH LOGIN CREATEDB CREATEROLE PASSWORD %s'.format(
|
self.create_or_update_role(self.admin['username'], self.admin['password'], 'CREATEDB CREATEROLE')
|
||||||
self.admin['username']), self.admin['password'])
|
|
||||||
|
|
||||||
def xlog_position(self):
|
def xlog_position(self):
|
||||||
return self.query("""SELECT pg_xlog_location_diff(CASE WHEN pg_is_in_recovery()
|
return self.query("""SELECT pg_xlog_location_diff(CASE WHEN pg_is_in_recovery()
|
||||||
THEN pg_last_xlog_replay_location()
|
THEN pg_last_xlog_replay_location()
|
||||||
ELSE pg_current_xlog_location()
|
ELSE pg_current_xlog_location()
|
||||||
END, '0/0')""").fetchone()[0]
|
END, '0/0')::bigint""").fetchone()[0]
|
||||||
|
|
||||||
def load_replication_slots(self):
|
def load_replication_slots(self):
|
||||||
if self.use_slots:
|
if self.use_slots and self.schedule_load_slots:
|
||||||
cursor = self.query("SELECT slot_name FROM pg_replication_slots WHERE slot_type='physical'")
|
cursor = self.query("SELECT slot_name FROM pg_replication_slots WHERE slot_type='physical'")
|
||||||
self.members = [r[0] for r in cursor]
|
self.replication_slots = [r[0] for r in cursor]
|
||||||
|
self.schedule_load_slots = False
|
||||||
|
|
||||||
def sync_replication_slots(self, members):
|
def sync_replication_slots(self, cluster):
|
||||||
if self.use_slots:
|
if self.use_slots:
|
||||||
# drop unused slots
|
try:
|
||||||
for slot in set(self.members) - set(members):
|
self.load_replication_slots()
|
||||||
self.query("""SELECT pg_drop_replication_slot(%s)
|
slots = [m.name for m in cluster.members if m.name != self.name] if self.role == 'master' else []
|
||||||
WHERE EXISTS(SELECT 1 FROM pg_replication_slots
|
# drop unused slots
|
||||||
WHERE slot_name = %s)""", slot, slot)
|
for slot in set(self.replication_slots) - set(slots):
|
||||||
|
self.query("""SELECT pg_drop_replication_slot(%s)
|
||||||
|
WHERE EXISTS(SELECT 1 FROM pg_replication_slots
|
||||||
|
WHERE slot_name = %s)""", slot, slot)
|
||||||
|
|
||||||
# create new slots
|
# create new slots
|
||||||
for slot in set(members) - set(self.members):
|
for slot in set(slots) - set(self.replication_slots):
|
||||||
self.query("""SELECT pg_create_physical_replication_slot(%s)
|
self.query("""SELECT pg_create_physical_replication_slot(%s)
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM pg_replication_slots
|
WHERE NOT EXISTS (SELECT 1 FROM pg_replication_slots
|
||||||
WHERE slot_name = %s)""", slot, slot)
|
WHERE slot_name = %s)""", slot, slot)
|
||||||
|
|
||||||
self.members = members
|
self.replication_slots = slots
|
||||||
|
except:
|
||||||
def create_replication_slots(self, cluster):
|
logger.exception('Exception when changing replication slots')
|
||||||
self.sync_replication_slots([m.name for m in cluster.members if m.name != self.name])
|
|
||||||
|
|
||||||
def drop_replication_slots(self):
|
|
||||||
self.sync_replication_slots([])
|
|
||||||
|
|
||||||
def last_operation(self):
|
def last_operation(self):
|
||||||
return str(self.xlog_position())
|
return str(self.xlog_position())
|
||||||
|
|
||||||
|
def bootstrap(self, current_leader=None):
|
||||||
|
"""
|
||||||
|
Initially bootstrap PostgreSQL, either by creating a data
|
||||||
|
directory with initdb, or by initalizing a replica from an
|
||||||
|
exiting leader. Failure in the first case always leads to
|
||||||
|
exception, since there is no point in continuing if initdb failed.
|
||||||
|
In the second case, however, a False is returned on failure, since
|
||||||
|
it is normal for the replica to retry a failed attempt to initialize
|
||||||
|
from the master.
|
||||||
|
"""
|
||||||
|
ret = False
|
||||||
|
if not current_leader:
|
||||||
|
ret = self.initialize() and self.start()
|
||||||
|
if ret:
|
||||||
|
self.create_replication_user()
|
||||||
|
self.create_connection_users()
|
||||||
|
else:
|
||||||
|
raise PostgresException("Could not bootstrap master PostgreSQL")
|
||||||
|
else:
|
||||||
|
if self.sync_from_leader(current_leader):
|
||||||
|
self.restore_configuration_files()
|
||||||
|
self.write_recovery_conf(current_leader)
|
||||||
|
ret = self.start()
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def move_data_directory(self):
|
||||||
|
if os.path.isdir(self.data_dir) and not self.is_running():
|
||||||
|
try:
|
||||||
|
new_name = '{0}_{1}'.format(self.data_dir, time.strftime('%Y-%m-%d-%H-%M-%S'))
|
||||||
|
logger.info('renaming data directory to %s', new_name)
|
||||||
|
os.rename(self.data_dir, new_name)
|
||||||
|
except:
|
||||||
|
logger.exception("Could not rename data directory %s", self.data_dir)
|
||||||
|
|
||||||
|
def remove_data_directory(self):
|
||||||
|
logger.info('Removing data directory: %s', self.data_dir)
|
||||||
|
try:
|
||||||
|
if os.path.islink(self.data_dir):
|
||||||
|
os.unlink(self.data_dir)
|
||||||
|
elif not os.path.exists(self.data_dir):
|
||||||
|
return
|
||||||
|
elif os.path.isfile(self.data_dir):
|
||||||
|
os.remove(self.data_dir)
|
||||||
|
elif os.path.isdir(self.data_dir):
|
||||||
|
shutil.rmtree(self.data_dir)
|
||||||
|
except:
|
||||||
|
logger.exception('Could not remove data directory %s', self.data_dir)
|
||||||
|
self.move_data_directory()
|
||||||
|
|
||||||
|
def basebackup(self, leader, env):
|
||||||
|
# creates a replica data dir using pg_basebackup.
|
||||||
|
# this is the default, built-in create_replica_method
|
||||||
|
# tries twice, then returns failure (as 1)
|
||||||
|
# uses "stream" as the xlog-method to avoid sync issues
|
||||||
|
master_connection = leader.conn_url
|
||||||
|
maxfailures = 2
|
||||||
|
ret = 1
|
||||||
|
for bbfailures in range(0, maxfailures):
|
||||||
|
try:
|
||||||
|
ret = subprocess.call(['pg_basebackup', '--pgdata=' + self.data_dir,
|
||||||
|
'--xlog-method=stream', "--dbname=" + master_connection], env=env)
|
||||||
|
if ret == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error when fetching backup with pg_basebackup: {0}'.format(e))
|
||||||
|
|
||||||
|
if bbfailures < maxfailures - 1:
|
||||||
|
logger.error('Trying again in 5 seconds')
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/python
|
#!/usr/bin/env python
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
@@ -65,8 +65,11 @@ class AWSConnection:
|
|||||||
return self._tag_ebs(new_role) and ret
|
return self._tag_ebs(new_role) and ret
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
def main():
|
||||||
if len(sys.argv) == 4 and sys.argv[1] in ('on_start', 'on_stop', 'on_role_change'):
|
if len(sys.argv) == 4 and sys.argv[1] in ('on_start', 'on_stop', 'on_role_change'):
|
||||||
AWSConnection(cluster_name=sys.argv[3]).on_role_change(sys.argv[2])
|
AWSConnection(cluster_name=sys.argv[3]).on_role_change(sys.argv[2])
|
||||||
else:
|
else:
|
||||||
sys.exit("Usage: {0} action role name".format(sys.argv[0]))
|
sys.exit("Usage: {0} action role name".format(sys.argv[0]))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
#!/usr/bin/python
|
|
||||||
# arguments are:
|
|
||||||
# - cluster scope
|
|
||||||
# - cluster role
|
|
||||||
# - master connection string
|
|
||||||
|
|
||||||
# for the AWS, the folliowing environment variables should be defined:
|
|
||||||
# - WALE_ENV_DIR: directory where WAL-E environment is kept
|
|
||||||
# - WAL_S3_BUCKET: a name of the S3 bucket for WAL-E
|
|
||||||
# - WALE_BACKUP_THRESHOLD_MEGABYTES if WAL amount is above that - use pg_basebackup
|
|
||||||
# - WALE_BACKUP_THRESHOLD_PERCENTAGE if WAL size exceeds a certain percentage of the
|
|
||||||
# latest backup size
|
|
||||||
from collections import namedtuple
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import psycopg2
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
if sys.hexversion >= 0x03000000:
|
|
||||||
long = int
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Restore(object):
|
|
||||||
|
|
||||||
def __init__(self, scope, role, datadir, connstring, env=None):
|
|
||||||
self.scope = scope
|
|
||||||
self.role = role
|
|
||||||
self.master_connection = Restore.parse_connstring(connstring)
|
|
||||||
self.data_dir = datadir
|
|
||||||
self.env = os.environ.copy() if not env else env
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_connstring(connstring):
|
|
||||||
# the connection string is in the form host= port= user=
|
|
||||||
# return the dictionary with all components as separare keys
|
|
||||||
result = {}
|
|
||||||
if connstring:
|
|
||||||
for x in connstring.split():
|
|
||||||
if x and '=' in x:
|
|
||||||
key, val = x.split('=')
|
|
||||||
result[key.strip()] = val.strip()
|
|
||||||
return result
|
|
||||||
|
|
||||||
def setup(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def replica_method(self):
|
|
||||||
return self.create_replica_with_pg_basebackup
|
|
||||||
|
|
||||||
def replica_fallback_method(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
""" creates a new replica using either pg_basebackup or WAL-E """
|
|
||||||
method_fn = self.replica_method()
|
|
||||||
ret = method_fn() if method_fn else 1
|
|
||||||
if ret != 0 and self.replica_fallback_method() is not None:
|
|
||||||
ret = (self.replica_fallback_method())()
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def create_replica_with_pg_basebackup(self):
|
|
||||||
try:
|
|
||||||
ret = subprocess.call(['pg_basebackup', '-R', '-D',
|
|
||||||
self.data_dir, '--host=' + self.master_connection['host'],
|
|
||||||
'--port=' + str(self.master_connection['port']),
|
|
||||||
'-U', self.master_connection['user']],
|
|
||||||
env=self.env)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Error when fetching backup with pg_basebackup: {0}'.format(e))
|
|
||||||
return 1
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
class WALERestore(Restore):
|
|
||||||
|
|
||||||
def __init__(self, scope, role, datadir, connstring, env=None):
|
|
||||||
super(WALERestore, self).__init__(scope, role, datadir, connstring, env)
|
|
||||||
# check the environment variables
|
|
||||||
self.init_error = False
|
|
||||||
|
|
||||||
def setup(self):
|
|
||||||
if (self.env.get('WAL_S3_BUCKET') and
|
|
||||||
self.env.get('WALE_BACKUP_THRESHOLD_PERCENTAGE') and
|
|
||||||
self.env.get('WALE_BACKUP_THRESHOLD_MEGABYTES')) is None:
|
|
||||||
self.init_error = True
|
|
||||||
else:
|
|
||||||
self.wal_e = namedtuple('WALE',
|
|
||||||
'threshold_megabytes threshold_backup_size_percentage s3_bucket cmd dir env_file')
|
|
||||||
|
|
||||||
self.wal_e.dir = self.env.get('WALE_ENV_DIR', '/home/postgres/etc/wal-e.d/env')
|
|
||||||
self.wal_e.env_file = os.path.join(self.wal_e.dir, 'WALE_S3_PREFIX')
|
|
||||||
|
|
||||||
self.wal_e.cmd = 'envdir {} wal-e --aws-instance-profile '.\
|
|
||||||
format(self.wal_e.dir)
|
|
||||||
self.wal_e.s3_bucket = self.env['WAL_S3_BUCKET']
|
|
||||||
self.wal_e.threshold_megabytes = self.env['WALE_BACKUP_THRESHOLD_MEGABYTES']
|
|
||||||
self.wal_e.threshold_backup_size_percentage = self.env['WALE_BACKUP_THRESHOLD_PERCENTAGE']
|
|
||||||
|
|
||||||
# check that the env file exists, create it otherwise
|
|
||||||
try:
|
|
||||||
if not os.path.exists(self.wal_e.dir):
|
|
||||||
os.makedirs(self.wal_e.dir)
|
|
||||||
# if this is a directory - make sure we have full access there
|
|
||||||
elif not (os.path.isdir(self.wal_e.dir) and os.access(self.wal_e.dir, os.R_OK | os.W_OK | os.X_OK)):
|
|
||||||
logger.error("Unable to access {} or not a directory".format(self.wal_e.dir))
|
|
||||||
self.init_error = True
|
|
||||||
# if WAL_S3_PREFIX is not there - create it and write the full path to bucket
|
|
||||||
if not self.init_error and not os.path.exists(self.wal_e.env_file):
|
|
||||||
with open(self.wal_e.env_file, 'w') as f:
|
|
||||||
f.write("s3://{0}/spilo/{1}/wal/\n".format(self.wal_e.s3_bucket, self.scope))
|
|
||||||
|
|
||||||
except (os.error, IOError) as e:
|
|
||||||
logger.error("{0}: WAL-e archiving is disabled".format(e))
|
|
||||||
self.init_error = True
|
|
||||||
|
|
||||||
def replica_method(self):
|
|
||||||
if self.should_use_s3_to_create_replica():
|
|
||||||
return self.create_replica_with_s3
|
|
||||||
return None
|
|
||||||
|
|
||||||
def replica_fallback_method(self):
|
|
||||||
return self.create_replica_with_pg_basebackup
|
|
||||||
|
|
||||||
def should_use_s3_to_create_replica(self):
|
|
||||||
""" determine whether it makes sense to use S3 and not pg_basebackup """
|
|
||||||
if self.init_error:
|
|
||||||
return False
|
|
||||||
|
|
||||||
threshold_megabytes = self.wal_e.threshold_megabytes
|
|
||||||
threshold_backup_size_percentage = self.wal_e.threshold_backup_size_percentage
|
|
||||||
|
|
||||||
try:
|
|
||||||
latest_backup = subprocess.check_output(self.wal_e.cmd.split() + ['backup-list', '--detail', 'LATEST'],
|
|
||||||
env=self.env)
|
|
||||||
# name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start
|
|
||||||
# wal_segment_backup_stop wal_segment_offset_backup_stop
|
|
||||||
# base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z
|
|
||||||
# 20310671 00000001000000000000007F 00000040
|
|
||||||
# 00000001000000000000007F 00000240
|
|
||||||
backup_strings = latest_backup.splitlines() if latest_backup else ()
|
|
||||||
if len(backup_strings) != 2:
|
|
||||||
return False
|
|
||||||
|
|
||||||
names = backup_strings[0].split()
|
|
||||||
vals = backup_strings[1].split()
|
|
||||||
if (len(names) != len(vals)) or (len(names) != 7):
|
|
||||||
return False
|
|
||||||
|
|
||||||
backup_info = dict(zip(names, vals))
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
logger.error("could not query wal-e latest backup: {}".format(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
backup_size = backup_info['expanded_size_bytes']
|
|
||||||
backup_start_segment = backup_info['wal_segment_backup_start']
|
|
||||||
backup_start_offset = backup_info['wal_segment_offset_backup_start']
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("unable to get some of S3 backup parameters: {}".format(e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
# WAL filename is XXXXXXXXYYYYYYYY000000ZZ, where X - timeline, Y - LSN logical log file,
|
|
||||||
# ZZ - 2 high digits of LSN offset. The rest of the offset is the provided decimal offset,
|
|
||||||
# that we have to convert to hex and 'prepend' to the high offset digits.
|
|
||||||
|
|
||||||
lsn_segment = backup_start_segment[8:16]
|
|
||||||
# first 2 characters of the result are 0x and the last one is L
|
|
||||||
lsn_offset = hex((long(backup_start_segment[16:32], 16) << 24) + long(backup_start_offset))[2:-1]
|
|
||||||
|
|
||||||
# construct the LSN from the segment and offset
|
|
||||||
backup_start_lsn = '{}/{}'.format(lsn_segment, lsn_offset)
|
|
||||||
|
|
||||||
conn = None
|
|
||||||
cursor = None
|
|
||||||
diff_in_bytes = long(backup_size)
|
|
||||||
try:
|
|
||||||
# get the difference in bytes between the current WAL location and the backup start offset
|
|
||||||
conn = psycopg2.connect(**(self.master_connection))
|
|
||||||
conn.autocommit = True
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("SELECT pg_xlog_location_diff(pg_current_xlog_location(), %s)", (backup_start_lsn,))
|
|
||||||
diff_in_bytes = long(cursor.fetchone()[0])
|
|
||||||
except psycopg2.Error as e:
|
|
||||||
logger.error('could not determine difference with the master location: {}'.format(e))
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
cursor and cursor.close()
|
|
||||||
conn and conn.close()
|
|
||||||
|
|
||||||
# if the size of the accumulated WAL segments is more than a certan percentage of the backup size
|
|
||||||
# or exceeds the pre-determined size - pg_basebackup is chosen instead.
|
|
||||||
return (diff_in_bytes < long(threshold_megabytes) * 1048576) and\
|
|
||||||
(diff_in_bytes < long(backup_size) * float(threshold_backup_size_percentage) / 100)
|
|
||||||
|
|
||||||
def create_replica_with_s3(self):
|
|
||||||
if self.init_error:
|
|
||||||
return 1
|
|
||||||
try:
|
|
||||||
ret = subprocess.call(self.wal_e.cmd + ' backup-fetch {} LATEST'.format(self.data_dir), env=self.env)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Error when fetching backup with WAL-E: {0}'.format(e))
|
|
||||||
return 1
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
if len(sys.argv) == 5:
|
|
||||||
# scope, role, datadir, connstring
|
|
||||||
restore = WALERestore(*(sys.argv[1:]))
|
|
||||||
restore.setup()
|
|
||||||
sys.exit(restore.run())
|
|
||||||
sys.exit("Usage: {0} scope role datadir connstring".format(sys.argv[0]))
|
|
||||||
Executable
+167
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
|
||||||
|
# sample script to clone new replicas using WAL-E restore
|
||||||
|
# falls back to pg_basebackup if WAL-E restore fails, or if
|
||||||
|
# WAL-E backup is too far behind
|
||||||
|
# note that pg_basebackup still expects to use restore from
|
||||||
|
# WAL-E for transaction logs
|
||||||
|
|
||||||
|
# theoretically should work with SWIFT, but not tested on it
|
||||||
|
|
||||||
|
# arguments are:
|
||||||
|
# - cluster scope
|
||||||
|
# - cluster role
|
||||||
|
# - master connection string
|
||||||
|
# - number of retries
|
||||||
|
# - envdir for the WALE env
|
||||||
|
# - WALE_BACKUP_THRESHOLD_MEGABYTES if WAL amount is above that - use pg_basebackup
|
||||||
|
# - WALE_BACKUP_THRESHOLD_PERCENTAGE if WAL size exceeds a certain percentage of the
|
||||||
|
|
||||||
|
# this script depends on an envdir defining the S3 bucket (or SWIFT dir),and login
|
||||||
|
# credentials per WALE Documentation.
|
||||||
|
|
||||||
|
# currently also requires that you configure the restore_command to use wal_e, example:
|
||||||
|
# recovery_conf:
|
||||||
|
# restore_command: envdir /etc/wal-e.d/env wal-e wal-fetch "%f" "%p" -p 1
|
||||||
|
|
||||||
|
from collections import namedtuple
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
if sys.hexversion >= 0x03000000:
|
||||||
|
long = int
|
||||||
|
|
||||||
|
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WALERestore(object):
|
||||||
|
|
||||||
|
def __init__(self, scope, datadir, connstring, env_dir, threshold_mb, threshold_pct, use_iam):
|
||||||
|
self.scope = scope
|
||||||
|
self.master_connection = connstring
|
||||||
|
self.data_dir = datadir
|
||||||
|
self.wal_e = namedtuple('wale', 'dir,threshold_mb,threshold_pct,iam_string,cmd')
|
||||||
|
self.wal_e.dir = env_dir
|
||||||
|
self.wal_e.threshold_mb = threshold_mb
|
||||||
|
self.wal_e.threshold_pct = threshold_pct
|
||||||
|
self.wal_e.iam_string = ' --aws-instance-profile ' if use_iam == 1 else ''
|
||||||
|
self.wal_e.cmd = 'envdir {0} wal-e {1} '.format(self.wal_e.dir, self.wal_e.iam_string)
|
||||||
|
self.init_error = (not os.path.exists(self.wal_e.dir))
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
""" creates a new replica using WAL-E """
|
||||||
|
if not self.init_error and self.should_use_s3_to_create_replica():
|
||||||
|
return self.create_replica_with_s3()
|
||||||
|
return 2
|
||||||
|
|
||||||
|
def should_use_s3_to_create_replica(self):
|
||||||
|
""" determine whether it makes sense to use S3 and not pg_basebackup """
|
||||||
|
|
||||||
|
threshold_megabytes = self.wal_e.threshold_mb
|
||||||
|
threshold_backup_size_percentage = self.wal_e.threshold_pct
|
||||||
|
|
||||||
|
try:
|
||||||
|
latest_backup = subprocess.check_output(self.wal_e.cmd.split() + ['backup-list', '--detail', 'LATEST'])
|
||||||
|
# name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start
|
||||||
|
# wal_segment_backup_stop wal_segment_offset_backup_stop
|
||||||
|
# base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z
|
||||||
|
# 20310671 00000001000000000000007F 00000040
|
||||||
|
# 00000001000000000000007F 00000240
|
||||||
|
backup_strings = latest_backup.splitlines() if latest_backup else ()
|
||||||
|
if len(backup_strings) != 2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
names = backup_strings[0].split()
|
||||||
|
vals = backup_strings[1].split()
|
||||||
|
if (len(names) != len(vals)) or (len(names) != 7):
|
||||||
|
return False
|
||||||
|
|
||||||
|
backup_info = dict(zip(names, vals))
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error("could not query wal-e latest backup: {}".format(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
backup_size = backup_info['expanded_size_bytes']
|
||||||
|
backup_start_segment = backup_info['wal_segment_backup_start']
|
||||||
|
backup_start_offset = backup_info['wal_segment_offset_backup_start']
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("unable to get some of WALE backup parameters: {}".format(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
# WAL filename is XXXXXXXXYYYYYYYY000000ZZ, where X - timeline, Y - LSN logical log file,
|
||||||
|
# ZZ - 2 high digits of LSN offset. The rest of the offset is the provided decimal offset,
|
||||||
|
# that we have to convert to hex and 'prepend' to the high offset digits.
|
||||||
|
|
||||||
|
lsn_segment = backup_start_segment[8:16]
|
||||||
|
# first 2 characters of the result are 0x and the last one is L
|
||||||
|
lsn_offset = hex((long(backup_start_segment[16:32], 16) << 24) + long(backup_start_offset))[2:-1]
|
||||||
|
|
||||||
|
# construct the LSN from the segment and offset
|
||||||
|
backup_start_lsn = '{}/{}'.format(lsn_segment, lsn_offset)
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
cursor = None
|
||||||
|
diff_in_bytes = long(backup_size)
|
||||||
|
try:
|
||||||
|
# get the difference in bytes between the current WAL location and the backup start offset
|
||||||
|
conn = psycopg2.connect(self.master_connection)
|
||||||
|
conn.autocommit = True
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT pg_xlog_location_diff(pg_current_xlog_location(), %s)", (backup_start_lsn,))
|
||||||
|
diff_in_bytes = long(cursor.fetchone()[0])
|
||||||
|
except psycopg2.Error as e:
|
||||||
|
logger.error('could not determine difference with the master location: {}'.format(e))
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
cursor and cursor.close()
|
||||||
|
conn and conn.close()
|
||||||
|
|
||||||
|
# if the size of the accumulated WAL segments is more than a certan percentage of the backup size
|
||||||
|
# or exceeds the pre-determined size - pg_basebackup is chosen instead.
|
||||||
|
return (diff_in_bytes < long(threshold_megabytes) * 1048576) and\
|
||||||
|
(diff_in_bytes < long(backup_size) * float(threshold_backup_size_percentage) / 100)
|
||||||
|
|
||||||
|
def create_replica_with_s3(self):
|
||||||
|
# if we're set up, restore the replica using fetch latest
|
||||||
|
try:
|
||||||
|
ret = subprocess.call(self.wal_e.cmd.split() + ['backup-fetch', '{}'.format(self.data_dir), 'LATEST'])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error when fetching backup with WAL-E: {0}'.format(e))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Script to image replicas using WAL-E')
|
||||||
|
parser.add_argument('--scope', required=True)
|
||||||
|
parser.add_argument('--role', required=False)
|
||||||
|
parser.add_argument('--datadir', required=True)
|
||||||
|
parser.add_argument('--connstring', required=True)
|
||||||
|
parser.add_argument('--retries', type=int, default=1)
|
||||||
|
parser.add_argument('--envdir', required=True)
|
||||||
|
parser.add_argument('--threshold_megabytes', type=int, default=10240)
|
||||||
|
parser.add_argument('--threshold_backup_size_percentage', type=int, default=30)
|
||||||
|
parser.add_argument('--use_iam', type=int, default=0)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# retry cloning in a loop
|
||||||
|
for retry in range(0, args.retries + 1):
|
||||||
|
restore = WALERestore(scope=args.scope, datadir=args.datadir, connstring=args.connstring,
|
||||||
|
env_dir=args.envdir, threshold_mb=args.threshold_megabytes,
|
||||||
|
threshold_pct=args.threshold_backup_size_percentage, use_iam=args.use_iam)
|
||||||
|
ret = restore.run()
|
||||||
|
if ret == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
sys.exit(ret)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
+13
-10
@@ -6,8 +6,9 @@ import signal
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from patroni.exceptions import DCSError
|
from patroni.exceptions import PatroniException
|
||||||
|
|
||||||
|
ignore_sigterm = False
|
||||||
interrupted_sleep = False
|
interrupted_sleep = False
|
||||||
reap_children = False
|
reap_children = False
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ def calculate_ttl(expiration):
|
|||||||
"""
|
"""
|
||||||
>>> calculate_ttl(None)
|
>>> calculate_ttl(None)
|
||||||
>>> calculate_ttl('2015-06-10 12:56:30.552539016Z')
|
>>> calculate_ttl('2015-06-10 12:56:30.552539016Z')
|
||||||
|
>>> calculate_ttl('2015-06-10T12:56:30.552539016Z') < 0
|
||||||
|
True
|
||||||
"""
|
"""
|
||||||
if not expiration:
|
if not expiration:
|
||||||
return None
|
return None
|
||||||
@@ -46,7 +49,10 @@ def calculate_ttl(expiration):
|
|||||||
|
|
||||||
|
|
||||||
def sigterm_handler(signo, stack_frame):
|
def sigterm_handler(signo, stack_frame):
|
||||||
sys.exit()
|
global ignore_sigterm
|
||||||
|
if not ignore_sigterm:
|
||||||
|
ignore_sigterm = True
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
|
||||||
def sigchld_handler(signo, stack_frame):
|
def sigchld_handler(signo, stack_frame):
|
||||||
@@ -86,7 +92,7 @@ def reap_children():
|
|||||||
reap_children = False
|
reap_children = False
|
||||||
|
|
||||||
|
|
||||||
class RetryFailedError(DCSError):
|
class RetryFailedError(PatroniException):
|
||||||
|
|
||||||
"""Raised when retrying an operation ultimately failed, after retrying the maximum number of attempts."""
|
"""Raised when retrying an operation ultimately failed, after retrying the maximum number of attempts."""
|
||||||
|
|
||||||
@@ -96,7 +102,7 @@ class Retry:
|
|||||||
"""Helper for retrying a method in the face of retry-able exceptions"""
|
"""Helper for retrying a method in the face of retry-able exceptions"""
|
||||||
|
|
||||||
def __init__(self, max_tries=1, delay=0.1, backoff=2, max_jitter=0.8, max_delay=3600,
|
def __init__(self, max_tries=1, delay=0.1, backoff=2, max_jitter=0.8, max_delay=3600,
|
||||||
sleep_func=time.sleep, deadline=None, retry_exceptions=DCSError):
|
sleep_func=sleep, deadline=None, retry_exceptions=PatroniException):
|
||||||
"""Create a :class:`Retry` instance for retrying function calls
|
"""Create a :class:`Retry` instance for retrying function calls
|
||||||
|
|
||||||
:param max_tries: How many times to retry the command. -1 means infinite tries.
|
:param max_tries: How many times to retry the command. -1 means infinite tries.
|
||||||
@@ -150,13 +156,10 @@ class Retry:
|
|||||||
if self._attempts == self.max_tries:
|
if self._attempts == self.max_tries:
|
||||||
raise RetryFailedError("Too many retry attempts")
|
raise RetryFailedError("Too many retry attempts")
|
||||||
self._attempts += 1
|
self._attempts += 1
|
||||||
sleeptime = self._cur_delay + (
|
sleeptime = self._cur_delay + (random.randint(0, self.max_jitter) / 100.0)
|
||||||
random.randint(0, self.max_jitter) / 100.0)
|
|
||||||
|
|
||||||
if self._cur_stoptime is not None and \
|
if self._cur_stoptime is not None and time.time() + sleeptime >= self._cur_stoptime:
|
||||||
time.time() + sleeptime >= self._cur_stoptime:
|
|
||||||
raise RetryFailedError("Exceeded retry deadline")
|
raise RetryFailedError("Exceeded retry deadline")
|
||||||
else:
|
else:
|
||||||
self.sleep_func(sleeptime)
|
self.sleep_func(sleeptime)
|
||||||
self._cur_delay = min(self._cur_delay * self.backoff,
|
self._cur_delay = min(self._cur_delay * self.backoff, self.max_delay)
|
||||||
self.max_delay)
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
__version__ = '0.2'
|
__version__ = '0.76'
|
||||||
|
|||||||
+109
-49
@@ -5,7 +5,8 @@ import time
|
|||||||
|
|
||||||
from kazoo.client import KazooClient, KazooState
|
from kazoo.client import KazooClient, KazooState
|
||||||
from kazoo.exceptions import NoNodeError, NodeExistsError
|
from kazoo.exceptions import NoNodeError, NodeExistsError
|
||||||
from patroni.dcs import AbstractDCS, Cluster, DCSError, Leader, Member, parse_connection_string
|
from patroni.dcs import AbstractDCS, Cluster, Failover, Leader, Member
|
||||||
|
from patroni.exceptions import DCSError
|
||||||
from patroni.utils import sleep
|
from patroni.utils import sleep
|
||||||
from requests.exceptions import RequestException
|
from requests.exceptions import RequestException
|
||||||
|
|
||||||
@@ -90,11 +91,9 @@ class ZooKeeper(AbstractDCS):
|
|||||||
'max_tries': -1},
|
'max_tries': -1},
|
||||||
connection_retry={'max_delay': 1, 'max_tries': -1})
|
connection_retry={'max_delay': 1, 'max_tries': -1})
|
||||||
self.client.add_listener(self.session_listener)
|
self.client.add_listener(self.session_listener)
|
||||||
self.cluster_event = self.client.handler.event_object()
|
|
||||||
|
|
||||||
|
self._my_member_data = None
|
||||||
self.fetch_cluster = True
|
self.fetch_cluster = True
|
||||||
self.members = []
|
|
||||||
self.leader = None
|
|
||||||
self.last_leader_operation = 0
|
self.last_leader_operation = 0
|
||||||
|
|
||||||
self.client.start(None)
|
self.client.start(None)
|
||||||
@@ -105,54 +104,72 @@ class ZooKeeper(AbstractDCS):
|
|||||||
|
|
||||||
def cluster_watcher(self, event):
|
def cluster_watcher(self, event):
|
||||||
self.fetch_cluster = True
|
self.fetch_cluster = True
|
||||||
self.cluster_event.set()
|
self.event.set()
|
||||||
|
|
||||||
def get_node(self, name, watch=None):
|
def get_node(self, key, watch=None):
|
||||||
try:
|
try:
|
||||||
return self.client.get(self.client_path(name), watch)
|
ret = self.client.get(key, watch)
|
||||||
|
return (ret[0].decode('utf-8'), ret[1])
|
||||||
except NoNodeError:
|
except NoNodeError:
|
||||||
pass
|
return None
|
||||||
except:
|
|
||||||
logger.exception('get_node')
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def member(name, value, znode):
|
def member(name, value, znode):
|
||||||
conn_url, api_url = parse_connection_string(value)
|
return Member.from_node(znode.version, name, znode.ephemeralOwner, value)
|
||||||
return Member(znode.mzxid, name, conn_url, api_url, None, None)
|
|
||||||
|
def get_children(self, key, watch=None):
|
||||||
|
try:
|
||||||
|
return self.client.get_children(key, watch)
|
||||||
|
except NoNodeError:
|
||||||
|
return []
|
||||||
|
|
||||||
def load_members(self):
|
def load_members(self):
|
||||||
members = []
|
members = []
|
||||||
for member in self.client.get_children(self.client_path('/members'), self.cluster_watcher):
|
for member in self.get_children(self.members_path, self.cluster_watcher):
|
||||||
data = self.get_node('/members/' + member)
|
data = self.get_node(self.members_path + member)
|
||||||
if data is not None:
|
if data is not None:
|
||||||
members.append(self.member(member, *data))
|
members.append(self.member(member, *data))
|
||||||
return members
|
return members
|
||||||
|
|
||||||
def _inner_load_cluster(self):
|
def _inner_load_cluster(self):
|
||||||
self.cluster_event.clear()
|
self.fetch_cluster = False
|
||||||
leader = self.get_node('/leader', self.cluster_watcher)
|
self.event.clear()
|
||||||
self.members = self.load_members()
|
nodes = set(self.get_children(self.client_path(''), self.cluster_watcher))
|
||||||
|
if not nodes:
|
||||||
|
self.fetch_cluster = True
|
||||||
|
|
||||||
|
# get initialize flag
|
||||||
|
initialize = (self.get_node(self.initialize_path) or [None])[0] if self._INITIALIZE in nodes else None
|
||||||
|
|
||||||
|
# get list of members
|
||||||
|
members = self.load_members() if self._MEMBERS[:-1] in nodes else []
|
||||||
|
|
||||||
|
# get leader
|
||||||
|
leader = self.get_node(self.leader_path) if self._LEADER in nodes else None
|
||||||
if leader:
|
if leader:
|
||||||
client_id = self.client.client_id
|
client_id = self.client.client_id
|
||||||
if leader[0] == self._name and client_id is not None and client_id[0] != leader[1].ephemeralOwner:
|
if leader[0] == self._name and client_id is not None and client_id[0] != leader[1].ephemeralOwner:
|
||||||
logger.info('I am leader but not owner of the session. Removing leader node')
|
logger.info('I am leader but not owner of the session. Removing leader node')
|
||||||
self.client.delete(self.client_path('/leader'))
|
self.client.delete(self.leader_path)
|
||||||
leader = None
|
leader = None
|
||||||
|
|
||||||
if leader:
|
if leader:
|
||||||
member = Member(-1, leader[0], None, None, None, None)
|
member = Member(-1, leader[0], None, {})
|
||||||
member = ([m for m in self.members if m.name == leader[0]] or [member])[0]
|
member = ([m for m in members if m.name == leader[0]] or [member])[0]
|
||||||
leader = Leader(leader[1].mzxid, None, None, member)
|
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
|
||||||
self.fetch_cluster = member.index == -1
|
self.fetch_cluster = member.index == -1
|
||||||
|
|
||||||
self.leader = leader
|
# failover key
|
||||||
if self.fetch_cluster:
|
failover = self.get_node(self.failover_path, watch=self.cluster_watcher) if self._FAILOVER in nodes else None
|
||||||
last_leader_operation = self.get_node('/optime/leader')
|
if failover:
|
||||||
if last_leader_operation:
|
failover = Failover.from_node(failover[1].version, failover[0])
|
||||||
self.last_leader_operation = int(last_leader_operation[0])
|
|
||||||
|
|
||||||
def get_cluster(self):
|
# get last leader operation
|
||||||
|
optime = self.get_node(self.leader_optime_path) if self._OPTIME in nodes and self.fetch_cluster else None
|
||||||
|
self.last_leader_operation = 0 if optime is None else int(optime[0])
|
||||||
|
self._cluster = Cluster(initialize, leader, self.last_leader_operation, members, failover)
|
||||||
|
|
||||||
|
def _load_cluster(self):
|
||||||
if self.exhibitor and self.exhibitor.poll():
|
if self.exhibitor and self.exhibitor.poll():
|
||||||
self.client.set_hosts(self.exhibitor.zookeeper_hosts)
|
self.client.set_hosts(self.exhibitor.zookeeper_hosts)
|
||||||
|
|
||||||
@@ -163,48 +180,77 @@ class ZooKeeper(AbstractDCS):
|
|||||||
logger.exception('get_cluster')
|
logger.exception('get_cluster')
|
||||||
self.session_listener(KazooState.LOST)
|
self.session_listener(KazooState.LOST)
|
||||||
raise ZooKeeperError('ZooKeeper in not responding properly')
|
raise ZooKeeperError('ZooKeeper in not responding properly')
|
||||||
return Cluster(True, self.leader, self.last_leader_operation, self.members)
|
|
||||||
|
|
||||||
def _create(self, path, value, **kwargs):
|
def _create(self, path, value, **kwargs):
|
||||||
try:
|
try:
|
||||||
self.client.retry(self.client.create, self.client_path(path), value, **kwargs)
|
self.client.retry(self.client.create, path, value.encode('utf-8'), **kwargs)
|
||||||
return True
|
return True
|
||||||
except:
|
except:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def attempt_to_acquire_leader(self):
|
def attempt_to_acquire_leader(self):
|
||||||
ret = self._create('/leader', self._name, makepath=True, ephemeral=True)
|
ret = self._create(self.leader_path, self._name, makepath=True, ephemeral=True)
|
||||||
ret or logger.info('Could not take out TTL lock')
|
ret or logger.info('Could not take out TTL lock')
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def race(self, path):
|
def set_failover_value(self, value, index=None):
|
||||||
return self._create(path, self._name, makepath=True)
|
|
||||||
|
|
||||||
def touch_member(self, connection_string, ttl=None):
|
|
||||||
for m in self.members:
|
|
||||||
if m.name == self._name:
|
|
||||||
return True
|
|
||||||
path = self.client_path('/members/' + self._name)
|
|
||||||
try:
|
try:
|
||||||
self.client.retry(self.client.create, path, connection_string, makepath=True, ephemeral=True)
|
self.client.retry(self.client.set, self.failover_path, value.encode('utf-8'), version=index or -1)
|
||||||
|
return True
|
||||||
|
except NoNodeError:
|
||||||
|
return value == '' or (not index and self._create(self.failover_path, value))
|
||||||
|
except:
|
||||||
|
logging.exception('set_failover_value')
|
||||||
|
return False
|
||||||
|
|
||||||
|
def initialize(self, create_new=True, sysid=""):
|
||||||
|
return self._create(self.initialize_path, sysid, makepath=True) if create_new \
|
||||||
|
else self.client.retry(self.client.set, self.initialize_path, sysid.encode("utf-8"))
|
||||||
|
|
||||||
|
def touch_member(self, data, ttl=None):
|
||||||
|
cluster = self.cluster
|
||||||
|
me = cluster and ([m for m in cluster.members if m.name == self._name] or [None])[0]
|
||||||
|
path = self.member_path
|
||||||
|
data = data.encode('utf-8')
|
||||||
|
create = not me
|
||||||
|
if me and self.client.client_id is not None and me.session != self.client.client_id[0]:
|
||||||
|
try:
|
||||||
|
self.client.retry(self.client.delete, path)
|
||||||
|
except NoNodeError:
|
||||||
|
pass
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
create = True
|
||||||
|
|
||||||
|
if not create and data == self._my_member_data:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
if create:
|
||||||
|
self.client.retry(self.client.create, path, data, makepath=True, ephemeral=True)
|
||||||
|
else:
|
||||||
|
self.client.retry(self.client.set, path, data)
|
||||||
|
self._my_member_data = data
|
||||||
return True
|
return True
|
||||||
except NodeExistsError:
|
except NodeExistsError:
|
||||||
try:
|
try:
|
||||||
self.client.retry(self.client.delete, path)
|
self.client.retry(self.client.set, path, data)
|
||||||
self.client.retry(self.client.create, path, connection_string, makepath=True, ephemeral=True)
|
self._my_member_data = data
|
||||||
return True
|
return True
|
||||||
except:
|
except:
|
||||||
logger.exception('touch_member')
|
logger.exception('touch_member')
|
||||||
|
except:
|
||||||
|
logger.exception('touch_member')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def take_leader(self):
|
def take_leader(self):
|
||||||
return self.attempt_to_acquire_leader()
|
return self.attempt_to_acquire_leader()
|
||||||
|
|
||||||
def update_leader(self, state_handler):
|
def write_leader_optime(self, last_operation):
|
||||||
last_operation = state_handler.last_operation()
|
last_operation = last_operation.encode('utf-8')
|
||||||
if last_operation != self.last_leader_operation:
|
if last_operation != self.last_leader_operation:
|
||||||
self.last_leader_operation = last_operation
|
self.last_leader_operation = last_operation
|
||||||
path = self.client_path('/optime/leader')
|
path = self.leader_optime_path
|
||||||
try:
|
try:
|
||||||
self.client.retry(self.client.set, path, last_operation)
|
self.client.retry(self.client.set, path, last_operation)
|
||||||
except NoNodeError:
|
except NoNodeError:
|
||||||
@@ -214,13 +260,27 @@ class ZooKeeper(AbstractDCS):
|
|||||||
logger.exception('Failed to create %s', path)
|
logger.exception('Failed to create %s', path)
|
||||||
except:
|
except:
|
||||||
logger.exception('Failed to update %s', path)
|
logger.exception('Failed to update %s', path)
|
||||||
|
|
||||||
|
def update_leader(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def delete_leader(self):
|
def delete_leader(self):
|
||||||
if isinstance(self.leader, Leader) and self.leader.name == self._name:
|
self.client.restart()
|
||||||
self.client.delete(self.client_path('/leader'))
|
self._my_member_data = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _cancel_initialization(self):
|
||||||
|
node = self.get_node(self.initialize_path)
|
||||||
|
if node:
|
||||||
|
self.client.delete(self.initialize_path, version=node[1].version)
|
||||||
|
|
||||||
|
def cancel_initialization(self):
|
||||||
|
try:
|
||||||
|
self.client.retry(self._cancel_initialization)
|
||||||
|
except:
|
||||||
|
logger.exception("Unable to delete initialize key")
|
||||||
|
|
||||||
def watch(self, timeout):
|
def watch(self, timeout):
|
||||||
self.cluster_event.wait(timeout)
|
if super(ZooKeeper, self).watch(timeout):
|
||||||
if self.cluster_event.isSet():
|
|
||||||
self.fetch_cluster = True
|
self.fetch_cluster = True
|
||||||
|
return self.fetch_cluster
|
||||||
|
|||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
from patroni.ctl import ctl
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
ctl()
|
||||||
+47
-7
@@ -4,6 +4,9 @@ scope: &scope batman
|
|||||||
restapi:
|
restapi:
|
||||||
listen: 127.0.0.1:8008
|
listen: 127.0.0.1:8008
|
||||||
connect_address: 127.0.0.1:8008
|
connect_address: 127.0.0.1:8008
|
||||||
|
auth: 'username:password'
|
||||||
|
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||||
|
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
|
||||||
etcd:
|
etcd:
|
||||||
scope: *scope
|
scope: *scope
|
||||||
ttl: *ttl
|
ttl: *ttl
|
||||||
@@ -31,6 +34,27 @@ postgresql:
|
|||||||
data_dir: data/postgresql0
|
data_dir: data/postgresql0
|
||||||
maximum_lag_on_failover: 1048576 # 1 megabyte in bytes
|
maximum_lag_on_failover: 1048576 # 1 megabyte in bytes
|
||||||
use_slots: True
|
use_slots: True
|
||||||
|
pgpass: /tmp/pgpass0
|
||||||
|
initdb: ## We allow the following options to be passed on to initdb
|
||||||
|
# - auth: authmethod
|
||||||
|
# - auth-host: authmethod
|
||||||
|
# - auth-local: authmethod
|
||||||
|
- encoding: UTF8
|
||||||
|
# - data-checksums # When pg_rewind is needed on 9.3, this needs to be enabled
|
||||||
|
# - locale: locale
|
||||||
|
# - lc-collate: locale
|
||||||
|
# - lc-ctype: locale
|
||||||
|
# - lc-messages: locale
|
||||||
|
# - lc-monetary: locale
|
||||||
|
# - lc-numeric: locale
|
||||||
|
# - lc-time: locale
|
||||||
|
# - text-search-config: CFG
|
||||||
|
# - xlogdir: directory
|
||||||
|
# - debug
|
||||||
|
# - noclean
|
||||||
|
pg_rewind:
|
||||||
|
username: postgres
|
||||||
|
password: zalando
|
||||||
pg_hba:
|
pg_hba:
|
||||||
- host all all 0.0.0.0/0 md5
|
- host all all 0.0.0.0/0 md5
|
||||||
- hostssl all all 0.0.0.0/0 md5
|
- hostssl all all 0.0.0.0/0 md5
|
||||||
@@ -39,23 +63,39 @@ postgresql:
|
|||||||
password: rep-pass
|
password: rep-pass
|
||||||
network: 127.0.0.1/32
|
network: 127.0.0.1/32
|
||||||
superuser:
|
superuser:
|
||||||
|
username: postgres
|
||||||
password: zalando
|
password: zalando
|
||||||
admin:
|
admin:
|
||||||
username: admin
|
username: admin
|
||||||
password: admin
|
password: admin
|
||||||
wal_e:
|
create_replica_method:
|
||||||
env_dir: /home/postgres/etc/wal-e.d/env
|
- basebackup
|
||||||
threshold_megabytes: 10240
|
# - wal_e
|
||||||
threshold_backup_size_percentage: 30
|
# commented-out example for wal-e provisioning
|
||||||
restore: scripts/restore.py
|
#create_replica_method: wal_e, basebackup
|
||||||
|
#wal_e:
|
||||||
|
#command: /patroni/scripts/wale_restore.py
|
||||||
|
#env_dir: /etc/wal-e.d/env
|
||||||
|
#threshold_megabytes: 10240
|
||||||
|
#threshold_backup_size_percentage: 30
|
||||||
|
#retries: 2
|
||||||
|
#use_iam: 1
|
||||||
#recovery_conf:
|
#recovery_conf:
|
||||||
#restore_command: cp ../wal_archive/%f %p
|
#restore_command: envdir /etc/wal-e.d/env wal-e wal-fetch "%f" "%p" -p 1
|
||||||
|
recovery_conf:
|
||||||
|
restore_command: cp ../wal_archive/%f %p
|
||||||
parameters:
|
parameters:
|
||||||
archive_mode: "on"
|
archive_mode: "on"
|
||||||
wal_level: hot_standby
|
wal_level: hot_standby
|
||||||
archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f
|
archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
|
||||||
max_wal_senders: 5
|
max_wal_senders: 5
|
||||||
wal_keep_segments: 8
|
wal_keep_segments: 8
|
||||||
archive_timeout: 1800s
|
archive_timeout: 1800s
|
||||||
max_replication_slots: 5
|
max_replication_slots: 5
|
||||||
hot_standby: "on"
|
hot_standby: "on"
|
||||||
|
wal_log_hints: "on"
|
||||||
|
tags:
|
||||||
|
nofailover: False
|
||||||
|
noloadbalance: False
|
||||||
|
clonefrom: False
|
||||||
|
replicatefrom: 127.0.0.1
|
||||||
|
|||||||
+47
-7
@@ -4,6 +4,9 @@ scope: &scope batman
|
|||||||
restapi:
|
restapi:
|
||||||
listen: 127.0.0.1:8009
|
listen: 127.0.0.1:8009
|
||||||
connect_address: 127.0.0.1:8009
|
connect_address: 127.0.0.1:8009
|
||||||
|
auth: 'username:password'
|
||||||
|
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||||
|
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
|
||||||
etcd:
|
etcd:
|
||||||
scope: *scope
|
scope: *scope
|
||||||
ttl: *ttl
|
ttl: *ttl
|
||||||
@@ -31,6 +34,27 @@ postgresql:
|
|||||||
data_dir: data/postgresql1
|
data_dir: data/postgresql1
|
||||||
maximum_lag_on_failover: 1048576 # 1 megabyte in bytes
|
maximum_lag_on_failover: 1048576 # 1 megabyte in bytes
|
||||||
use_slots: True
|
use_slots: True
|
||||||
|
pgpass: /tmp/pgpass1
|
||||||
|
initdb: ## We allow the following options to be passed on to initdb
|
||||||
|
# - auth: authmethod
|
||||||
|
# - auth-host: authmethod
|
||||||
|
# - auth-local: authmethod
|
||||||
|
- encoding: UTF8
|
||||||
|
# - data-checksums # When pg_rewind is needed on 9.3, this needs to be enabled
|
||||||
|
# - locale: locale
|
||||||
|
# - lc-collate: locale
|
||||||
|
# - lc-ctype: locale
|
||||||
|
# - lc-messages: locale
|
||||||
|
# - lc-monetary: locale
|
||||||
|
# - lc-numeric: locale
|
||||||
|
# - lc-time: locale
|
||||||
|
# - text-search-config: CFG
|
||||||
|
# - xlogdir: directory
|
||||||
|
# - debug
|
||||||
|
# - noclean
|
||||||
|
pg_rewind:
|
||||||
|
username: postgres
|
||||||
|
password: zalando
|
||||||
pg_hba:
|
pg_hba:
|
||||||
- host all all 0.0.0.0/0 md5
|
- host all all 0.0.0.0/0 md5
|
||||||
- hostssl all all 0.0.0.0/0 md5
|
- hostssl all all 0.0.0.0/0 md5
|
||||||
@@ -39,23 +63,39 @@ postgresql:
|
|||||||
password: rep-pass
|
password: rep-pass
|
||||||
network: 127.0.0.1/32
|
network: 127.0.0.1/32
|
||||||
superuser:
|
superuser:
|
||||||
|
user: postgres
|
||||||
password: zalando
|
password: zalando
|
||||||
admin:
|
admin:
|
||||||
username: admin
|
username: admin
|
||||||
password: admin
|
password: admin
|
||||||
|
# commented-out example for wal-e provisioning
|
||||||
|
create_replica_method:
|
||||||
|
- basebackup
|
||||||
|
# - wal_e
|
||||||
|
# commented-out example for wal-e provisioning
|
||||||
|
#wal_e:
|
||||||
|
#command: /patroni/scripts/wale_restore.py
|
||||||
|
#env_dir: /home/postgres/etc/wal-e.d/env
|
||||||
|
#threshold_megabytes: 10240
|
||||||
|
#threshold_backup_size_percentage: 30
|
||||||
|
#retries: 2
|
||||||
|
#use_iam: 1
|
||||||
#recovery_conf:
|
#recovery_conf:
|
||||||
#restore_command: cp ../wal_archive/%f %p
|
#restore_command: envdir /etc/wal-e.d/env wal-e wal-fetch "%f" "%p" -p 1
|
||||||
wal_e:
|
recovery_conf:
|
||||||
env_dir: /home/postgres/etc/wal-e.d/env
|
restore_command: cp ../wal_archive/%f %p
|
||||||
threshold_megabytes: 10240
|
|
||||||
threshold_backup_size_percentage: 30
|
|
||||||
restore: scripts/restore.py
|
|
||||||
parameters:
|
parameters:
|
||||||
archive_mode: "on"
|
archive_mode: "on"
|
||||||
wal_level: hot_standby
|
wal_level: hot_standby
|
||||||
archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f
|
archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
|
||||||
max_wal_senders: 5
|
max_wal_senders: 5
|
||||||
wal_keep_segments: 8
|
wal_keep_segments: 8
|
||||||
archive_timeout: 1800s
|
archive_timeout: 1800s
|
||||||
max_replication_slots: 5
|
max_replication_slots: 5
|
||||||
hot_standby: "on"
|
hot_standby: "on"
|
||||||
|
wal_log_hints: "on"
|
||||||
|
tags:
|
||||||
|
nofailover: False
|
||||||
|
noloadbalance: False
|
||||||
|
clonefrom: False
|
||||||
|
replicatefrom: 127.0.0.1
|
||||||
|
|||||||
+1
-1
@@ -27,5 +27,5 @@ git push
|
|||||||
|
|
||||||
python3 setup.py sdist bdist_wheel upload
|
python3 setup.py sdist bdist_wheel upload
|
||||||
|
|
||||||
git tag ${version}
|
git tag v${version}
|
||||||
git push --tags
|
git push --tags
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
boto
|
boto
|
||||||
dnspython
|
dnspython
|
||||||
mock
|
mock
|
||||||
psycopg2
|
psycopg2>=2.6.1
|
||||||
PyYAML
|
PyYAML
|
||||||
requests
|
requests
|
||||||
six >= 1.7
|
six >= 1.7
|
||||||
kazoo>=2.2.1
|
kazoo>=2.2.1
|
||||||
python-etcd>=0.4.1
|
python-etcd>=0.4.1
|
||||||
|
click>=4.1
|
||||||
|
prettytable>=0.7
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
boto
|
boto
|
||||||
mock
|
mock
|
||||||
dnspython3
|
dnspython3
|
||||||
psycopg2
|
psycopg2>=2.6.1
|
||||||
PyYAML
|
PyYAML
|
||||||
requests
|
requests
|
||||||
six
|
six
|
||||||
kazoo>=2.2.1
|
kazoo>=2.2.1
|
||||||
python-etcd>=0.4.1
|
python-etcd>=0.4.1
|
||||||
|
click>=4.1
|
||||||
|
prettytable>=0.7
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ VERSION = read_version(MAIN_PACKAGE)
|
|||||||
DESCRIPTION = 'PostgreSQL High-Available orchestrator and CLI'
|
DESCRIPTION = 'PostgreSQL High-Available orchestrator and CLI'
|
||||||
LICENSE = 'The MIT License'
|
LICENSE = 'The MIT License'
|
||||||
URL = 'https://github.com/zalando/patroni'
|
URL = 'https://github.com/zalando/patroni'
|
||||||
AUTHOR = 'Alexander Kukushkin, Alexey Klyukin, Feike Steenbergen'
|
AUTHOR = 'Alexander Kukushkin, Oleksii Kliukin, Feike Steenbergen'
|
||||||
AUTHOR_EMAIL = '[email protected], [email protected], [email protected]'
|
AUTHOR_EMAIL = '[email protected], [email protected], [email protected]'
|
||||||
KEYWORDS = 'etcd governor patroni postgresql postgres ha zookeeper streaming replication'
|
KEYWORDS = 'etcd governor patroni postgresql postgres ha zookeeper streaming replication'
|
||||||
|
|
||||||
@@ -56,7 +56,10 @@ CLASSIFIERS = [
|
|||||||
'Programming Language :: Python :: Implementation :: CPython',
|
'Programming Language :: Python :: Implementation :: CPython',
|
||||||
]
|
]
|
||||||
|
|
||||||
CONSOLE_SCRIPTS = ['patroni = patroni:main']
|
CONSOLE_SCRIPTS = ['patroni = patroni:main',
|
||||||
|
'patronictl = patroni.ctl:ctl',
|
||||||
|
"patroni_wale_restore = patroni.scripts.wale_restore:main",
|
||||||
|
"patroni_aws = patroni.scripts.aws:main"]
|
||||||
|
|
||||||
|
|
||||||
class PyTest(TestCommand):
|
class PyTest(TestCommand):
|
||||||
@@ -89,7 +92,6 @@ class PyTest(TestCommand):
|
|||||||
params = {'args': self.test_args}
|
params = {'args': self.test_args}
|
||||||
if self.cov:
|
if self.cov:
|
||||||
params['args'] += self.cov
|
params['args'] += self.cov
|
||||||
params['plugins'] = ['cov']
|
|
||||||
if self.junitxml:
|
if self.junitxml:
|
||||||
params['args'] += self.junitxml
|
params['args'] += self.junitxml
|
||||||
params['args'] += ['--doctest-modules', MAIN_PACKAGE, '-s', '-vv']
|
params['args'] += ['--doctest-modules', MAIN_PACKAGE, '-s', '-vv']
|
||||||
|
|||||||
+131
-16
@@ -1,16 +1,21 @@
|
|||||||
import psycopg2
|
import psycopg2
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from mock import Mock, patch
|
||||||
from patroni.api import RestApiHandler, RestApiServer
|
from patroni.api import RestApiHandler, RestApiServer
|
||||||
|
from patroni.dcs import Member
|
||||||
from six import BytesIO as IO
|
from six import BytesIO as IO
|
||||||
from test_postgresql import psycopg2_connect
|
from six.moves import BaseHTTPServer
|
||||||
|
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler
|
||||||
|
import socket
|
||||||
|
from test_postgresql import psycopg2_connect, MockCursor
|
||||||
|
|
||||||
|
|
||||||
def throws(*args, **kwargs):
|
class MockPostgresql(Mock):
|
||||||
raise psycopg2.OperationalError()
|
|
||||||
|
|
||||||
|
name = 'test'
|
||||||
class MockPostgresql:
|
state = 'running'
|
||||||
|
role = 'master'
|
||||||
|
|
||||||
def connection(self):
|
def connection(self):
|
||||||
return psycopg2_connect()
|
return psycopg2_connect()
|
||||||
@@ -19,10 +24,33 @@ class MockPostgresql:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class MockHa(Mock):
|
||||||
|
|
||||||
|
dcs = Mock()
|
||||||
|
state_handler = MockPostgresql()
|
||||||
|
|
||||||
|
def schedule_restart(self):
|
||||||
|
return 'restart'
|
||||||
|
|
||||||
|
def schedule_reinitialize(self):
|
||||||
|
return 'reinitialize'
|
||||||
|
|
||||||
|
def restart(self):
|
||||||
|
return (True, '')
|
||||||
|
|
||||||
|
def restart_scheduled(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def fetch_nodes_statuses(self, members):
|
||||||
|
return [[None, True, None, None, {}]]
|
||||||
|
|
||||||
|
|
||||||
class MockPatroni:
|
class MockPatroni:
|
||||||
|
|
||||||
def __init__(self):
|
postgresql = MockPostgresql()
|
||||||
self.postgresql = MockPostgresql()
|
ha = MockHa()
|
||||||
|
dcs = Mock()
|
||||||
|
tags = {}
|
||||||
|
|
||||||
|
|
||||||
class MockRequest:
|
class MockRequest:
|
||||||
@@ -36,18 +64,105 @@ class MockRequest:
|
|||||||
|
|
||||||
class MockRestApiServer(RestApiServer):
|
class MockRestApiServer(RestApiServer):
|
||||||
|
|
||||||
def __init__(self, Handler, path, *args):
|
def __init__(self, Handler, path):
|
||||||
self.patroni = MockPatroni()
|
self.socket = 0
|
||||||
if len(args) > 0:
|
BaseHTTPServer.HTTPServer.__init__ = Mock()
|
||||||
self.query = args[0]
|
MockRestApiServer._BaseServer__is_shut_down = Mock()
|
||||||
|
MockRestApiServer._BaseServer__shutdown_request = True
|
||||||
|
config = {'listen': '127.0.0.1:8008', 'auth': 'test:test', 'certfile': 'dumb'}
|
||||||
|
super(MockRestApiServer, self).__init__(MockPatroni(), config)
|
||||||
Handler(MockRequest(path), ('0.0.0.0', 8080), self)
|
Handler(MockRequest(path), ('0.0.0.0', 8080), self)
|
||||||
|
|
||||||
|
|
||||||
|
@patch('ssl.wrap_socket', Mock(return_value=0))
|
||||||
class TestRestApiHandler(unittest.TestCase):
|
class TestRestApiHandler(unittest.TestCase):
|
||||||
|
|
||||||
def __init__(self, method_name='runTest'):
|
|
||||||
super(TestRestApiHandler, self).__init__(method_name)
|
|
||||||
|
|
||||||
def test_do_GET(self):
|
def test_do_GET(self):
|
||||||
MockRestApiServer(RestApiHandler, b'GET /')
|
MockRestApiServer(RestApiHandler, b'GET /replica')
|
||||||
MockRestApiServer(RestApiHandler, b'GET /', throws)
|
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={})):
|
||||||
|
MockRestApiServer(RestApiHandler, b'GET /replica')
|
||||||
|
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
|
||||||
|
MockRestApiServer(RestApiHandler, b'GET /replica')
|
||||||
|
MockRestApiServer(RestApiHandler, b'GET /master')
|
||||||
|
MockPatroni.dcs.cluster.leader.name = MockPostgresql.name
|
||||||
|
MockRestApiServer(RestApiHandler, b'GET /replica')
|
||||||
|
MockPatroni.dcs.cluster = None
|
||||||
|
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
|
||||||
|
MockRestApiServer(RestApiHandler, b'GET /master')
|
||||||
|
with patch.object(MockHa, 'restart_scheduled', Mock(return_value=True)):
|
||||||
|
MockRestApiServer(RestApiHandler, b'GET /master')
|
||||||
|
MockRestApiServer(RestApiHandler, b'GET /master')
|
||||||
|
|
||||||
|
def test_do_OPTIONS(self):
|
||||||
|
MockRestApiServer(RestApiHandler, b'OPTIONS / HTTP/1.0')
|
||||||
|
|
||||||
|
with patch.object(BaseHTTPRequestHandler, 'handle_one_request') as mock_handle_request:
|
||||||
|
mock_handle_request.side_effect = socket.error("foo")
|
||||||
|
MockRestApiServer(RestApiHandler, b'OPTIONS / HTTP/1.0')
|
||||||
|
|
||||||
|
# make sure socket.error gets propagated via wfile object in finalize()
|
||||||
|
with patch.object(MockRequest, 'makefile') as makefile:
|
||||||
|
makefile.return_value.closed = False
|
||||||
|
makefile.return_value.readline.side_effect = lambda x: b"foo"
|
||||||
|
makefile.return_value.flush = Mock(side_effect=socket.error("foo"))
|
||||||
|
MockRestApiServer(RestApiHandler, b'OPTIONS / HTTP/1.0')
|
||||||
|
|
||||||
|
def test_do_GET_patroni(self):
|
||||||
|
MockRestApiServer(RestApiHandler, b'GET /patroni')
|
||||||
|
|
||||||
|
def test_basicauth(self):
|
||||||
|
MockRestApiServer(RestApiHandler, b'POST /restart HTTP/1.0')
|
||||||
|
MockRestApiServer(RestApiHandler, b'POST /restart HTTP/1.0\nAuthorization:')
|
||||||
|
|
||||||
|
def test_do_POST_restart(self):
|
||||||
|
request = b'POST /restart HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0'
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
with patch.object(MockHa, 'restart', Mock(side_effect=Exception)):
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
|
||||||
|
@patch.object(MockHa, 'dcs')
|
||||||
|
def test_do_POST_reinitialize(self, dcs):
|
||||||
|
cluster = dcs.get_cluster.return_value
|
||||||
|
request = b'POST /reinitialize HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0'
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
cluster.is_unlocked.return_value = False
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
with patch.object(MockHa, 'schedule_reinitialize', Mock(return_value=None)):
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
cluster.leader.name = 'test'
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
|
||||||
|
@patch('time.sleep', Mock())
|
||||||
|
def test_RestApiServer_query(self):
|
||||||
|
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError)):
|
||||||
|
MockRestApiServer(RestApiHandler, b'GET /patroni')
|
||||||
|
with patch.object(MockPostgresql, 'connection', Mock(side_effect=psycopg2.OperationalError)):
|
||||||
|
MockRestApiServer(RestApiHandler, b'GET /patroni')
|
||||||
|
|
||||||
|
@patch('time.sleep', Mock())
|
||||||
|
@patch.object(MockHa, 'dcs')
|
||||||
|
def test_do_POST_failover(self, dcs):
|
||||||
|
cluster = dcs.get_cluster.return_value
|
||||||
|
request = b'POST /failover HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0\n' +\
|
||||||
|
b'Content-Length: 25\n\n{"leader": "postgresql1"}'
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
cluster.leader.name = 'postgresql1'
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
cluster.members = [Member(0, 'postgresql0', 30, {'api_url': 'http'})]
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
with patch.object(MockPatroni, 'dcs') as d:
|
||||||
|
cluster = d.get_cluster.return_value
|
||||||
|
cluster.leader.name = 'postgresql0'
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
cluster.leader.name = 'postgresql1'
|
||||||
|
cluster.failover = None
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
d.get_cluster = Mock(side_effect=Exception())
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
d.manual_failover.return_value = False
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
with patch.object(MockHa, 'fetch_nodes_statuses', Mock(return_value=[])):
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
request = b'POST /failover HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0\n' +\
|
||||||
|
b'Content-Length: 50\n\n{"leader": "postgresql1", "member": "postgresql2"}'
|
||||||
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from mock import Mock, patch
|
||||||
|
from patroni.async_executor import AsyncExecutor
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncExecutor(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.a = AsyncExecutor()
|
||||||
|
|
||||||
|
@patch.object(Thread, 'start', Mock())
|
||||||
|
def test_run_async(self):
|
||||||
|
self.a.run_async(Mock(return_value=True))
|
||||||
|
|
||||||
|
def test_run(self):
|
||||||
|
self.a.run(Mock(side_effect=Exception()))
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import unittest
|
||||||
|
import psycopg2
|
||||||
|
import requests
|
||||||
|
import patroni.exceptions
|
||||||
|
import etcd
|
||||||
|
from mock import patch, Mock
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
from patroni.ctl import ctl, members, store_config, load_config, output_members, post_patroni, get_dcs, \
|
||||||
|
wait_for_leader, get_all_members, get_any_member, get_cursor, query_member, configure
|
||||||
|
from patroni.ha import Ha
|
||||||
|
from patroni.etcd import Etcd, Client
|
||||||
|
from test_ha import get_cluster_initialized_without_leader, get_cluster_initialized_with_leader, \
|
||||||
|
get_cluster_initialized_with_only_leader, MockPostgresql, MockPatroni, run_async, \
|
||||||
|
get_cluster_not_initialized_without_leader
|
||||||
|
from test_etcd import etcd_read, etcd_write, requests_get, MockResponse
|
||||||
|
from test_postgresql import MockConnect, psycopg2_connect
|
||||||
|
|
||||||
|
CONFIG_FILE_PATH = './test-ctl.yaml'
|
||||||
|
|
||||||
|
def test_rw_config():
|
||||||
|
runner = CliRunner()
|
||||||
|
config = {'a':'b'}
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
store_config(config, CONFIG_FILE_PATH + '/dummy')
|
||||||
|
os.remove(CONFIG_FILE_PATH + '/dummy')
|
||||||
|
os.rmdir(CONFIG_FILE_PATH)
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
result = load_config(CONFIG_FILE_PATH, None)
|
||||||
|
assert 'Could not load configuration file' in result.output
|
||||||
|
|
||||||
|
os.mkdir(CONFIG_FILE_PATH)
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
store_config(config, CONFIG_FILE_PATH)
|
||||||
|
|
||||||
|
os.rmdir(CONFIG_FILE_PATH)
|
||||||
|
|
||||||
|
store_config(config, CONFIG_FILE_PATH)
|
||||||
|
load_config(CONFIG_FILE_PATH, None)
|
||||||
|
load_config(CONFIG_FILE_PATH, '0.0.0.0')
|
||||||
|
|
||||||
|
@patch('patroni.ctl.load_config', Mock(return_value={'dcs': {'scheme': 'etcd', 'hostname': 'localhost', 'port': 4001}}))
|
||||||
|
class TestCtl(unittest.TestCase):
|
||||||
|
|
||||||
|
@patch.object(Client, 'machines')
|
||||||
|
def setUp(self, mock_machines):
|
||||||
|
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
|
||||||
|
self.p = MockPostgresql()
|
||||||
|
self.e = Etcd('foo', {'ttl': 30, 'host': 'ok:2379', 'scope': 'test'})
|
||||||
|
self.e.client.read = etcd_read
|
||||||
|
self.e.client.write = etcd_write
|
||||||
|
self.e.client.delete = Mock(side_effect=etcd.EtcdException())
|
||||||
|
self.ha = Ha(MockPatroni(self.p, self.e))
|
||||||
|
self.ha._async_executor.run_async = run_async
|
||||||
|
self.ha.old_cluster = self.e.get_cluster()
|
||||||
|
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||||
|
self.ha.load_cluster_from_dcs = Mock()
|
||||||
|
|
||||||
|
@patch('psycopg2.connect', psycopg2_connect)
|
||||||
|
def test_get_cursor(self):
|
||||||
|
c = get_cursor(get_cluster_initialized_without_leader(), role='master')
|
||||||
|
assert c is None
|
||||||
|
|
||||||
|
c = get_cursor(get_cluster_initialized_with_leader(), role='master')
|
||||||
|
assert c is not None
|
||||||
|
|
||||||
|
c = get_cursor(get_cluster_initialized_with_leader(), role='replica')
|
||||||
|
# # MockCursor returns pg_is_in_recovery as false
|
||||||
|
assert c is None
|
||||||
|
|
||||||
|
c = get_cursor(get_cluster_initialized_with_leader(), role='any')
|
||||||
|
assert c is not None
|
||||||
|
|
||||||
|
def test_output_members(self):
|
||||||
|
cluster = get_cluster_initialized_with_leader()
|
||||||
|
output_members(cluster, name='abc', format='pretty')
|
||||||
|
output_members(cluster, name='abc', format='json')
|
||||||
|
output_members(cluster, name='abc', format='tsv')
|
||||||
|
|
||||||
|
@patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
|
||||||
|
@patch('patroni.etcd.Etcd.get_etcd_client', Mock(return_value=None))
|
||||||
|
@patch('patroni.etcd.Etcd.set_failover_value', Mock(return_value=None))
|
||||||
|
@patch('patroni.ctl.wait_for_leader', Mock(return_value=get_cluster_initialized_with_leader()))
|
||||||
|
@patch('requests.get', requests_get)
|
||||||
|
@patch('requests.post', requests_get)
|
||||||
|
@patch('patroni.ctl.post_patroni', Mock(return_value=MockResponse()))
|
||||||
|
def test_failover(self):
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
with patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader())):
|
||||||
|
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
|
||||||
|
other
|
||||||
|
y''')
|
||||||
|
assert 'Failing over to new leader' in result.output
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
|
||||||
|
other
|
||||||
|
N''')
|
||||||
|
assert 'Aborting failover' in str(result.exception)
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
|
||||||
|
leader
|
||||||
|
y''')
|
||||||
|
assert 'target and source are the same' in str(result.exception)
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
|
||||||
|
Reality
|
||||||
|
y''')
|
||||||
|
assert 'Reality does not exist' in str(result.exception)
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, ['failover', 'dummy', '--force'])
|
||||||
|
assert 'Failing over to new leader' in result.output
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='dummy')
|
||||||
|
assert 'is not the leader of cluster' in str(result.exception)
|
||||||
|
|
||||||
|
with patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_only_leader())):
|
||||||
|
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
|
||||||
|
other
|
||||||
|
y''')
|
||||||
|
assert 'No candidates found to failover to' in str(result.exception)
|
||||||
|
|
||||||
|
with patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_without_leader())):
|
||||||
|
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
|
||||||
|
other
|
||||||
|
y''')
|
||||||
|
assert 'This cluster has no master' in str(result.exception)
|
||||||
|
|
||||||
|
with patch('patroni.ctl.post_patroni', Mock(side_effect=Exception())):
|
||||||
|
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
|
||||||
|
other
|
||||||
|
y''')
|
||||||
|
assert 'falling back to DCS' in result.output
|
||||||
|
assert 'Failover failed' in result.output
|
||||||
|
|
||||||
|
mocked = Mock()
|
||||||
|
mocked.return_value.status_code = 500
|
||||||
|
with patch('patroni.ctl.post_patroni', Mock(return_value=mocked)):
|
||||||
|
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
|
||||||
|
other
|
||||||
|
y''')
|
||||||
|
assert 'Failover failed, details' in result.output
|
||||||
|
|
||||||
|
# with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader())):
|
||||||
|
# result = runner.invoke(ctl, ['failover', 'alpha', '--dcs', '8.8.8.8'], input='nonsense')
|
||||||
|
# assert 'is not the leader of cluster' in str(result.exception)
|
||||||
|
|
||||||
|
# result = runner.invoke(ctl, ['failover', 'alpha', '--dcs', '8.8.8.8', '--master', 'nonsense'])
|
||||||
|
# assert 'is not the leader of cluster' in str(result.exception)
|
||||||
|
|
||||||
|
# result = runner.invoke(ctl, ['failover', 'alpha', '--dcs', '8.8.8.8'], input='leader\nother\nn')
|
||||||
|
# assert 'Aborting failover' in str(result.exception)
|
||||||
|
|
||||||
|
# with patch('patroni.ctl.wait_for_leader', Mock(return_value = get_cluster_initialized_with_leader())):
|
||||||
|
# result = runner.invoke(ctl, ['failover', 'alpha', '--dcs', '8.8.8.8'], input='leader\nother\nY')
|
||||||
|
# assert 'master did not change after' in result.output
|
||||||
|
|
||||||
|
# result = runner.invoke(ctl, ['failover', 'alpha', '--dcs', '8.8.8.8'], input='leader\nother\nY')
|
||||||
|
# assert 'Failover failed' in result.output
|
||||||
|
|
||||||
|
def test_(self):
|
||||||
|
self.assertRaises(patroni.exceptions.PatroniCtlException, get_dcs, {'scheme': 'dummy'}, 'dummy')
|
||||||
|
|
||||||
|
@patch('psycopg2.connect', psycopg2_connect)
|
||||||
|
@patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None)))
|
||||||
|
def test_query(self):
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
with patch('patroni.ctl.get_dcs', Mock(return_value=self.e)):
|
||||||
|
result = runner.invoke(ctl, [
|
||||||
|
'query',
|
||||||
|
'alpha',
|
||||||
|
'--member',
|
||||||
|
'abc',
|
||||||
|
'--role',
|
||||||
|
'master',
|
||||||
|
])
|
||||||
|
assert 'mutually exclusive' in str(result.exception)
|
||||||
|
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
dummy_file = open('dummy', 'w')
|
||||||
|
dummy_file.write('SELECT 1')
|
||||||
|
dummy_file.close()
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, [
|
||||||
|
'query',
|
||||||
|
'alpha',
|
||||||
|
'--file',
|
||||||
|
'dummy',
|
||||||
|
'--command',
|
||||||
|
'dummy',
|
||||||
|
])
|
||||||
|
assert 'mutually exclusive' in str(result.exception)
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, ['query', 'alpha', '--file', 'dummy'])
|
||||||
|
|
||||||
|
os.remove('dummy')
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, ['query', 'alpha', '--command', 'SELECT 1'])
|
||||||
|
assert 'mock column' in result.output
|
||||||
|
|
||||||
|
@patch('patroni.ctl.get_cursor', Mock(return_value=MockConnect().cursor()))
|
||||||
|
def test_query_member(self):
|
||||||
|
rows = query_member(None, None, None, 'master', 'SELECT pg_is_in_recovery()')
|
||||||
|
assert 'False' in str(rows)
|
||||||
|
|
||||||
|
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()')
|
||||||
|
assert rows == (None, None)
|
||||||
|
|
||||||
|
with patch('patroni.ctl.get_cursor', Mock(return_value=None)):
|
||||||
|
rows = query_member(None, None, None, None, 'SELECT pg_is_in_recovery()')
|
||||||
|
assert 'No connection to' in str(rows)
|
||||||
|
|
||||||
|
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()')
|
||||||
|
assert 'No connection to' in str(rows)
|
||||||
|
|
||||||
|
with patch('patroni.ctl.get_cursor', Mock(side_effect=psycopg2.OperationalError('bla'))):
|
||||||
|
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()')
|
||||||
|
|
||||||
|
with patch('test_postgresql.MockCursor.execute', Mock(side_effect=psycopg2.OperationalError('bla'))):
|
||||||
|
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()')
|
||||||
|
|
||||||
|
@patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
|
||||||
|
def test_dsn(self):
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
with patch('patroni.ctl.get_dcs', Mock(return_value=self.e)):
|
||||||
|
result = runner.invoke(ctl, ['dsn', 'alpha', '--dcs', '8.8.8.8'])
|
||||||
|
assert 'host=127.0.0.1 port=5435' in result.output
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, [
|
||||||
|
'dsn',
|
||||||
|
'alpha',
|
||||||
|
'--role',
|
||||||
|
'master',
|
||||||
|
'--member',
|
||||||
|
'dummy',
|
||||||
|
])
|
||||||
|
assert 'mutually exclusive' in str(result.exception)
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, ['dsn', 'alpha', '--member', 'dummy'])
|
||||||
|
assert 'Can not find' in str(result.exception)
|
||||||
|
|
||||||
|
# result = runner.invoke(ctl, ['dsn', 'alpha', '--dcs', '8.8.8.8', '--role', 'replica'])
|
||||||
|
# assert 'host=127.0.0.1 port=5436' in result.output
|
||||||
|
|
||||||
|
@patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
|
||||||
|
@patch('patroni.etcd.Etcd.get_etcd_client', Mock(return_value=None))
|
||||||
|
@patch('requests.get', requests_get)
|
||||||
|
@patch('requests.post', requests_get)
|
||||||
|
def test_restart_reinit(self):
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, ['restart', 'alpha', '--dcs', '8.8.8.8'], input='y')
|
||||||
|
result = runner.invoke(ctl, ['reinit', 'alpha', '--dcs', '8.8.8.8'], input='y')
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, ['restart', 'alpha', '--dcs', '8.8.8.8'], input='N')
|
||||||
|
result = runner.invoke(ctl, [
|
||||||
|
'restart',
|
||||||
|
'alpha',
|
||||||
|
'--dcs',
|
||||||
|
'8.8.8.8',
|
||||||
|
'dummy',
|
||||||
|
'--any',
|
||||||
|
], input='y')
|
||||||
|
assert 'not a member' in str(result.exception)
|
||||||
|
|
||||||
|
with patch('requests.post', Mock(return_value=MockResponse())):
|
||||||
|
result = runner.invoke(ctl, ['restart', 'alpha', '--dcs', '8.8.8.8'], input='y')
|
||||||
|
|
||||||
|
@patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
|
||||||
|
@patch('patroni.etcd.Etcd.get_etcd_client', Mock(return_value=None))
|
||||||
|
def test_remove(self):
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'], input='alpha\nslave')
|
||||||
|
assert 'Please confirm' in result.output
|
||||||
|
assert 'You are about to remove all' in result.output
|
||||||
|
assert 'You did not exactly type' in str(result.exception)
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'], input='''alpha
|
||||||
|
Yes I am aware
|
||||||
|
slave''')
|
||||||
|
assert 'You did not specify the current master of the cluster' in str(result.exception)
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'], input='beta\nleader')
|
||||||
|
assert 'Cluster names specified do not match' in str(result.exception)
|
||||||
|
|
||||||
|
with patch('patroni.etcd.Etcd.get_cluster', get_cluster_initialized_with_leader):
|
||||||
|
result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'],
|
||||||
|
input='''alpha
|
||||||
|
Yes I am aware
|
||||||
|
leader''')
|
||||||
|
assert 'object has no attribute' in str(result.exception)
|
||||||
|
|
||||||
|
with patch('patroni.ctl.get_dcs', Mock(return_value=Mock())):
|
||||||
|
result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'],
|
||||||
|
input='''alpha
|
||||||
|
Yes I am aware
|
||||||
|
leader''')
|
||||||
|
assert 'We have not implemented this for DCS of type' in str(result.exception)
|
||||||
|
|
||||||
|
@patch('patroni.etcd.Etcd.watch', Mock(return_value=None))
|
||||||
|
@patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
|
||||||
|
def test_wait_for_leader(self):
|
||||||
|
dcs = self.e
|
||||||
|
self.assertRaises(patroni.exceptions.PatroniCtlException, wait_for_leader, dcs, 0)
|
||||||
|
|
||||||
|
cluster = wait_for_leader(dcs=dcs, timeout=2)
|
||||||
|
assert cluster.leader.member.name == 'leader'
|
||||||
|
|
||||||
|
def test_post_patroni(self):
|
||||||
|
member = get_cluster_initialized_with_leader().leader.member
|
||||||
|
self.assertRaises(requests.exceptions.ConnectionError, post_patroni, member, 'dummy', {})
|
||||||
|
|
||||||
|
def test_ctl(self):
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
runner.invoke(ctl, ['list'])
|
||||||
|
|
||||||
|
result = runner.invoke(ctl, ['--help'])
|
||||||
|
assert 'Usage:' in result.output
|
||||||
|
|
||||||
|
def test_get_any_member(self):
|
||||||
|
m = get_any_member(get_cluster_initialized_without_leader(), role='master')
|
||||||
|
assert m is None
|
||||||
|
|
||||||
|
m = get_any_member(get_cluster_initialized_with_leader(), role='master')
|
||||||
|
assert m.name == 'leader'
|
||||||
|
|
||||||
|
def test_get_all_members(self):
|
||||||
|
r = list(get_all_members(get_cluster_initialized_without_leader(), role='master'))
|
||||||
|
assert len(r) == 0
|
||||||
|
|
||||||
|
r = list(get_all_members(get_cluster_initialized_with_leader(), role='master'))
|
||||||
|
assert len(r) == 1
|
||||||
|
assert r[0].name == 'leader'
|
||||||
|
|
||||||
|
r = list(get_all_members(get_cluster_initialized_with_leader(), role='replica'))
|
||||||
|
assert len(r) == 1
|
||||||
|
assert r[0].name == 'other'
|
||||||
|
|
||||||
|
r = list(get_all_members(get_cluster_initialized_without_leader(), role='replica'))
|
||||||
|
assert len(r) == 2
|
||||||
|
|
||||||
|
@patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
|
||||||
|
@patch('patroni.etcd.Etcd.get_etcd_client', Mock(return_value=None))
|
||||||
|
@patch('requests.get', requests_get)
|
||||||
|
@patch('requests.post', requests_get)
|
||||||
|
def test_members(self):
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(members, ['alpha'])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_configure(self):
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(configure, [
|
||||||
|
'--dcs',
|
||||||
|
'abc',
|
||||||
|
'-c',
|
||||||
|
'dummy',
|
||||||
|
'-n',
|
||||||
|
'bla',
|
||||||
|
])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
+62
-72
@@ -1,17 +1,14 @@
|
|||||||
import datetime
|
|
||||||
import dns.resolver
|
|
||||||
import etcd
|
import etcd
|
||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
import urllib3
|
import urllib3
|
||||||
import socket
|
import socket
|
||||||
import time
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from dns.exception import DNSException
|
from dns.exception import DNSException
|
||||||
from mock import Mock, patch
|
from mock import Mock, patch
|
||||||
from patroni.dcs import Cluster, DCSError, Leader, Member
|
from patroni.dcs import Cluster, DCSError, Leader
|
||||||
from patroni.etcd import Client, Etcd
|
from patroni.etcd import Client, Etcd, EtcdError
|
||||||
|
|
||||||
|
|
||||||
class MockResponse:
|
class MockResponse:
|
||||||
@@ -20,6 +17,7 @@ class MockResponse:
|
|||||||
self.status_code = 200
|
self.status_code = 200
|
||||||
self.content = '{}'
|
self.content = '{}'
|
||||||
self.ok = True
|
self.ok = True
|
||||||
|
self.text = ''
|
||||||
|
|
||||||
def json(self):
|
def json(self):
|
||||||
return json.loads(self.content)
|
return json.loads(self.content)
|
||||||
@@ -40,11 +38,10 @@ class MockResponse:
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
class MockPostgresql:
|
class MockPostgresql(Mock):
|
||||||
name = ''
|
|
||||||
|
|
||||||
def last_operation(self):
|
def last_operation(self):
|
||||||
return 0
|
return '0'
|
||||||
|
|
||||||
|
|
||||||
def requests_get(url, **kwargs):
|
def requests_get(url, **kwargs):
|
||||||
@@ -53,6 +50,8 @@ def requests_get(url, **kwargs):
|
|||||||
response = MockResponse()
|
response = MockResponse()
|
||||||
if url.startswith('http://local'):
|
if url.startswith('http://local'):
|
||||||
raise requests.exceptions.RequestException()
|
raise requests.exceptions.RequestException()
|
||||||
|
elif ':8011/patroni' in url:
|
||||||
|
response.content = '{"role": "replica", "xlog": {"replayed_location": 0}, "tags": {}}'
|
||||||
elif url.endswith('/members'):
|
elif url.endswith('/members'):
|
||||||
if url.startswith('http://error'):
|
if url.startswith('http://error'):
|
||||||
response.content = '[{}]'
|
response.content = '[{}]'
|
||||||
@@ -67,11 +66,11 @@ def requests_get(url, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def etcd_watch(key, index=None, timeout=None, recursive=None):
|
def etcd_watch(key, index=None, timeout=None, recursive=None):
|
||||||
if timeout == 1:
|
if timeout == 2.0:
|
||||||
raise urllib3.exceptions.TimeoutError
|
raise urllib3.exceptions.TimeoutError
|
||||||
elif timeout == 5:
|
elif timeout == 5.0:
|
||||||
return etcd.EtcdResult('delete', {})
|
return etcd.EtcdResult('delete', {})
|
||||||
elif timeout == 10:
|
elif timeout == 10.0:
|
||||||
raise etcd.EtcdException
|
raise etcd.EtcdException
|
||||||
elif index == 20729:
|
elif index == 20729:
|
||||||
return etcd.EtcdResult('set', {'value': 'postgresql1', 'modifiedIndex': index + 1})
|
return etcd.EtcdResult('set', {'value': 'postgresql1', 'modifiedIndex': index + 1})
|
||||||
@@ -82,23 +81,21 @@ def etcd_watch(key, index=None, timeout=None, recursive=None):
|
|||||||
def etcd_write(key, value, **kwargs):
|
def etcd_write(key, value, **kwargs):
|
||||||
if key == '/service/exists/leader':
|
if key == '/service/exists/leader':
|
||||||
raise etcd.EtcdAlreadyExist
|
raise etcd.EtcdAlreadyExist
|
||||||
if key == '/service/test/leader':
|
if key == '/service/test/leader' or key == '/patroni/test/leader':
|
||||||
if kwargs.get('prevValue', None) == 'foo' or not kwargs.get('prevExist', True):
|
if kwargs.get('prevValue', None) == 'foo' or not kwargs.get('prevExist', True):
|
||||||
return True
|
return True
|
||||||
raise etcd.EtcdException
|
raise etcd.EtcdException
|
||||||
|
|
||||||
|
|
||||||
def etcd_delete(key, **kwargs):
|
|
||||||
raise etcd.EtcdException
|
|
||||||
|
|
||||||
|
|
||||||
def etcd_read(key, **kwargs):
|
def etcd_read(key, **kwargs):
|
||||||
if key == '/service/noleader':
|
if key == '/service/noleader/':
|
||||||
raise DCSError('noleader')
|
raise DCSError('noleader')
|
||||||
elif key == '/service/nocluster':
|
elif key == '/service/nocluster/':
|
||||||
raise etcd.EtcdKeyNotFound
|
raise etcd.EtcdKeyNotFound
|
||||||
|
|
||||||
response = {"action": "get", "node": {"key": "/service/batman5", "dir": True, "nodes": [
|
response = {"action": "get", "node": {"key": "/service/batman5", "dir": True, "nodes": [
|
||||||
|
{"key": "/service/batman5/failover", "value": "",
|
||||||
|
"modifiedIndex": 1582, "createdIndex": 1582},
|
||||||
{"key": "/service/batman5/initialize", "value": "postgresql0",
|
{"key": "/service/batman5/initialize", "value": "postgresql0",
|
||||||
"modifiedIndex": 1582, "createdIndex": 1582},
|
"modifiedIndex": 1582, "createdIndex": 1582},
|
||||||
{"key": "/service/batman5/leader", "value": "postgresql1",
|
{"key": "/service/batman5/leader", "value": "postgresql1",
|
||||||
@@ -110,31 +107,23 @@ def etcd_read(key, **kwargs):
|
|||||||
"modifiedIndex": 20437, "createdIndex": 20437},
|
"modifiedIndex": 20437, "createdIndex": 20437},
|
||||||
{"key": "/service/batman5/members", "dir": True, "nodes": [
|
{"key": "/service/batman5/members", "dir": True, "nodes": [
|
||||||
{"key": "/service/batman5/members/postgresql1",
|
{"key": "/service/batman5/members/postgresql1",
|
||||||
"value": "postgres://replicator:[email protected]:5434/postgres"
|
"value": "postgres://replicator:[email protected]:5434/postgres" +
|
||||||
+ "?application_name=http://127.0.0.1:8009/patroni",
|
"?application_name=http://127.0.0.1:8009/patroni",
|
||||||
"expiration": "2015-05-15T09:10:59.949384522Z", "ttl": 21,
|
"expiration": "2015-05-15T09:10:59.949384522Z", "ttl": 21,
|
||||||
"modifiedIndex": 20727, "createdIndex": 20727},
|
"modifiedIndex": 20727, "createdIndex": 20727},
|
||||||
{"key": "/service/batman5/members/postgresql0",
|
{"key": "/service/batman5/members/postgresql0",
|
||||||
"value": "postgres://replicator:[email protected]:5433/postgres"
|
"value": "postgres://replicator:[email protected]:5433/postgres" +
|
||||||
+ "?application_name=http://127.0.0.1:8008/patroni",
|
"?application_name=http://127.0.0.1:8008/patroni",
|
||||||
"expiration": "2015-05-15T09:11:09.611860899Z", "ttl": 30,
|
"expiration": "2015-05-15T09:11:09.611860899Z", "ttl": 30,
|
||||||
"modifiedIndex": 20730, "createdIndex": 20730}],
|
"modifiedIndex": 20730, "createdIndex": 20730}],
|
||||||
"modifiedIndex": 1581, "createdIndex": 1581}], "modifiedIndex": 1581, "createdIndex": 1581}}
|
"modifiedIndex": 1581, "createdIndex": 1581}], "modifiedIndex": 1581, "createdIndex": 1581}}
|
||||||
return etcd.EtcdResult(**response)
|
return etcd.EtcdResult(**response)
|
||||||
|
|
||||||
|
|
||||||
def time_sleep(_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class SleepException(Exception):
|
class SleepException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def time_sleep_exception(_):
|
|
||||||
raise SleepException()
|
|
||||||
|
|
||||||
|
|
||||||
class MockSRV:
|
class MockSRV:
|
||||||
port = 2380
|
port = 2380
|
||||||
target = '127.0.0.1'
|
target = '127.0.0.1'
|
||||||
@@ -151,7 +140,7 @@ def dns_query(name, type):
|
|||||||
def socket_getaddrinfo(*args):
|
def socket_getaddrinfo(*args):
|
||||||
if args[0] == 'ok':
|
if args[0] == 'ok':
|
||||||
return [(2, 1, 6, '', ('127.0.0.1', 2379)), (2, 1, 6, '', ('127.0.0.1', 2379))]
|
return [(2, 1, 6, '', ('127.0.0.1', 2379)), (2, 1, 6, '', ('127.0.0.1', 2379))]
|
||||||
raise socket.error()
|
raise socket.error
|
||||||
|
|
||||||
|
|
||||||
def http_request(method, url, **kwargs):
|
def http_request(method, url, **kwargs):
|
||||||
@@ -160,28 +149,14 @@ def http_request(method, url, **kwargs):
|
|||||||
raise socket.error
|
raise socket.error
|
||||||
|
|
||||||
|
|
||||||
class TestMember(unittest.TestCase):
|
@patch('dns.resolver.query', dns_query)
|
||||||
|
@patch('socket.getaddrinfo', socket_getaddrinfo)
|
||||||
def __init__(self, method_name='runTest'):
|
@patch('requests.get', requests_get)
|
||||||
super(TestMember, self).__init__(method_name)
|
|
||||||
|
|
||||||
def test_real_ttl(self):
|
|
||||||
now = datetime.datetime.utcnow()
|
|
||||||
member = Member(0, 'a', 'b', 'c', (now + datetime.timedelta(seconds=2)).strftime('%Y-%m-%dT%H:%M:%S.%fZ'), None)
|
|
||||||
self.assertLess(member.real_ttl(), 2)
|
|
||||||
self.assertEquals(Member(0, 'a', 'b', 'c', '', None).real_ttl(), -1)
|
|
||||||
|
|
||||||
|
|
||||||
class TestClient(unittest.TestCase):
|
class TestClient(unittest.TestCase):
|
||||||
|
|
||||||
def __init__(self, method_name='runTest'):
|
@patch('dns.resolver.query', dns_query)
|
||||||
self.setUp = self.set_up
|
@patch('requests.get', requests_get)
|
||||||
super(TestClient, self).__init__(method_name)
|
def setUp(self):
|
||||||
|
|
||||||
def set_up(self):
|
|
||||||
socket.getaddrinfo = socket_getaddrinfo
|
|
||||||
requests.get = requests_get
|
|
||||||
dns.resolver.query = dns_query
|
|
||||||
with patch.object(etcd.Client, 'machines') as mock_machines:
|
with patch.object(etcd.Client, 'machines') as mock_machines:
|
||||||
mock_machines.__get__ = Mock(return_value=['http://localhost:2379', 'http://localhost:4001'])
|
mock_machines.__get__ = Mock(return_value=['http://localhost:2379', 'http://localhost:4001'])
|
||||||
self.client = Client({'discovery_srv': 'test'})
|
self.client = Client({'discovery_srv': 'test'})
|
||||||
@@ -191,6 +166,11 @@ class TestClient(unittest.TestCase):
|
|||||||
self.client._base_uri = 'http://localhost:4001'
|
self.client._base_uri = 'http://localhost:4001'
|
||||||
self.client._machines_cache = ['http://localhost:2379']
|
self.client._machines_cache = ['http://localhost:2379']
|
||||||
self.client.api_execute('/', 'GET')
|
self.client.api_execute('/', 'GET')
|
||||||
|
self.client._update_machines_cache = False
|
||||||
|
self.client._base_uri = 'http://localhost:4001'
|
||||||
|
self.client._machines_cache = []
|
||||||
|
self.assertRaises(etcd.EtcdConnectionFailed, self.client.api_execute, '/', 'GET')
|
||||||
|
self.assertTrue(self.client._update_machines_cache)
|
||||||
|
|
||||||
def test_get_srv_record(self):
|
def test_get_srv_record(self):
|
||||||
self.assertEquals(self.client.get_srv_record('blabla'), [])
|
self.assertEquals(self.client.get_srv_record('blabla'), [])
|
||||||
@@ -206,11 +186,11 @@ class TestClient(unittest.TestCase):
|
|||||||
self.assertRaises(etcd.EtcdException, self.client._result_from_response, response)
|
self.assertRaises(etcd.EtcdException, self.client._result_from_response, response)
|
||||||
|
|
||||||
def test__get_machines_cache_from_srv(self):
|
def test__get_machines_cache_from_srv(self):
|
||||||
self.client.get_srv_record = lambda e: [('localhost', 2380)]
|
self.client.get_srv_record = Mock(return_value=[('localhost', 2380)])
|
||||||
self.client._get_machines_cache_from_srv('blabla')
|
self.client._get_machines_cache_from_srv('blabla')
|
||||||
|
|
||||||
def test__get_machines_cache_from_dns(self):
|
def test__get_machines_cache_from_dns(self):
|
||||||
self.client._get_machines_cache_from_dns('ok:2379')
|
self.client._get_machines_cache_from_dns('error:2379')
|
||||||
|
|
||||||
def test__load_machines_cache(self):
|
def test__load_machines_cache(self):
|
||||||
self.client._config = {}
|
self.client._config = {}
|
||||||
@@ -219,25 +199,26 @@ class TestClient(unittest.TestCase):
|
|||||||
self.assertRaises(etcd.EtcdException, self.client._load_machines_cache)
|
self.assertRaises(etcd.EtcdException, self.client._load_machines_cache)
|
||||||
|
|
||||||
|
|
||||||
|
@patch('requests.get', requests_get)
|
||||||
class TestEtcd(unittest.TestCase):
|
class TestEtcd(unittest.TestCase):
|
||||||
|
|
||||||
def __init__(self, method_name='runTest'):
|
def setUp(self):
|
||||||
self.setUp = self.set_up
|
|
||||||
super(TestEtcd, self).__init__(method_name)
|
|
||||||
|
|
||||||
def set_up(self):
|
|
||||||
time.sleep = time_sleep
|
|
||||||
with patch.object(Client, 'machines') as mock_machines:
|
with patch.object(Client, 'machines') as mock_machines:
|
||||||
mock_machines.__get__ = Mock(return_value=['http://localhost:2379', 'http://localhost:4001'])
|
mock_machines.__get__ = Mock(return_value=['http://localhost:2379', 'http://localhost:4001'])
|
||||||
self.etcd = Etcd('foo', {'ttl': 30, 'host': 'localhost:2379', 'scope': 'test'})
|
self.etcd = Etcd('foo', {'namespace': '/patroni/', 'ttl': 30, 'host': 'localhost:2379', 'scope': 'test'})
|
||||||
self.etcd.client.write = etcd_write
|
self.etcd.client.write = etcd_write
|
||||||
self.etcd.client.read = etcd_read
|
self.etcd.client.read = etcd_read
|
||||||
|
self.etcd.client.delete = Mock(side_effect=etcd.EtcdException())
|
||||||
|
|
||||||
|
def test_base_path(self):
|
||||||
|
self.assertEquals(self.etcd._base_path, '/patroni/test')
|
||||||
|
|
||||||
|
@patch('dns.resolver.query', dns_query)
|
||||||
def test_get_etcd_client(self):
|
def test_get_etcd_client(self):
|
||||||
time.sleep = time_sleep_exception
|
|
||||||
with patch.object(etcd.Client, 'machines') as mock_machines:
|
with patch.object(etcd.Client, 'machines') as mock_machines:
|
||||||
mock_machines.__get__ = Mock(side_effect=etcd.EtcdException)
|
mock_machines.__get__ = Mock(side_effect=etcd.EtcdException)
|
||||||
self.assertRaises(SleepException, self.etcd.get_etcd_client, {'discovery_srv': 'test'})
|
with patch('time.sleep', Mock(side_effect=SleepException())):
|
||||||
|
self.assertRaises(SleepException, self.etcd.get_etcd_client, {'discovery_srv': 'test'})
|
||||||
|
|
||||||
def test_get_cluster(self):
|
def test_get_cluster(self):
|
||||||
self.assertIsInstance(self.etcd.get_cluster(), Cluster)
|
self.assertIsInstance(self.etcd.get_cluster(), Cluster)
|
||||||
@@ -257,27 +238,36 @@ class TestEtcd(unittest.TestCase):
|
|||||||
def test_take_leader(self):
|
def test_take_leader(self):
|
||||||
self.assertFalse(self.etcd.take_leader())
|
self.assertFalse(self.etcd.take_leader())
|
||||||
|
|
||||||
def testattempt_to_acquire_leader(self):
|
def test_attempt_to_acquire_leader(self):
|
||||||
self.etcd._base_path = '/service/exists'
|
self.etcd._base_path = '/service/exists'
|
||||||
self.assertFalse(self.etcd.attempt_to_acquire_leader())
|
self.assertFalse(self.etcd.attempt_to_acquire_leader())
|
||||||
self.etcd._base_path = '/service/failed'
|
self.etcd._base_path = '/service/failed'
|
||||||
self.assertFalse(self.etcd.attempt_to_acquire_leader())
|
self.assertFalse(self.etcd.attempt_to_acquire_leader())
|
||||||
|
|
||||||
def test_update_leader(self):
|
def test_write_leader_optime(self):
|
||||||
self.assertTrue(self.etcd.update_leader(MockPostgresql()))
|
self.etcd.write_leader_optime('0')
|
||||||
|
|
||||||
def test_race(self):
|
def test_update_leader(self):
|
||||||
self.assertFalse(self.etcd.race(''))
|
self.assertTrue(self.etcd.update_leader())
|
||||||
|
|
||||||
|
def test_initialize(self):
|
||||||
|
self.assertFalse(self.etcd.initialize())
|
||||||
|
|
||||||
|
def test_cancel_initializion(self):
|
||||||
|
self.assertFalse(self.etcd.cancel_initialization())
|
||||||
|
|
||||||
def test_delete_leader(self):
|
def test_delete_leader(self):
|
||||||
self.etcd.client.delete = etcd_delete
|
|
||||||
self.assertFalse(self.etcd.delete_leader())
|
self.assertFalse(self.etcd.delete_leader())
|
||||||
|
|
||||||
def test_watch(self):
|
def test_watch(self):
|
||||||
self.etcd.client.watch = etcd_watch
|
self.etcd.client.watch = etcd_watch
|
||||||
self.etcd.watch(100)
|
self.etcd.watch(0)
|
||||||
self.etcd.get_cluster()
|
self.etcd.get_cluster()
|
||||||
self.etcd.watch(1)
|
self.etcd.watch(1.5)
|
||||||
self.etcd.watch(5)
|
self.etcd.watch(4.5)
|
||||||
self.etcd.watch(10)
|
self.etcd.watch(9.5)
|
||||||
self.etcd.watch(100)
|
self.etcd.watch(100)
|
||||||
|
|
||||||
|
@patch('patroni.etcd.Etcd.retry', Mock(side_effect=AttributeError("foo")))
|
||||||
|
def test_other_exceptions(self):
|
||||||
|
self.assertRaises(EtcdError, self.etcd.cancel_initialization)
|
||||||
|
|||||||
+238
-51
@@ -1,10 +1,12 @@
|
|||||||
|
import etcd
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from mock import Mock, patch
|
from mock import Mock, MagicMock, patch
|
||||||
from patroni.dcs import Cluster, DCSError
|
from patroni.dcs import Cluster, Failover, Leader, Member
|
||||||
from patroni.etcd import Client, Etcd
|
from patroni.etcd import Client, Etcd
|
||||||
|
from patroni.exceptions import DCSError, PostgresException
|
||||||
from patroni.ha import Ha
|
from patroni.ha import Ha
|
||||||
from test_etcd import etcd_read, etcd_write
|
from test_etcd import socket_getaddrinfo, etcd_read, etcd_write, requests_get
|
||||||
|
|
||||||
|
|
||||||
def true(*args, **kwargs):
|
def true(*args, **kwargs):
|
||||||
@@ -15,18 +17,41 @@ def false(*args, **kwargs):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class MockPostgresql:
|
def get_cluster(initialize, leader, members, failover):
|
||||||
|
return Cluster(initialize, leader, None, members, failover)
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.name = 'postgresql0'
|
def get_cluster_not_initialized_without_leader():
|
||||||
self.is_promoted = False
|
return get_cluster(None, None, [], None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cluster_initialized_without_leader(leader=False, failover=None):
|
||||||
|
m = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres',
|
||||||
|
'api_url': 'http://127.0.0.1:8008/patroni', 'xlog_location':4})
|
||||||
|
l = Leader(0, 0, m) if leader else None
|
||||||
|
o = Member(0, 'other', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres',
|
||||||
|
'api_url': 'http://127.0.0.1:8011/patroni'})
|
||||||
|
return get_cluster(True, l, [m, o], failover)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cluster_initialized_with_leader(failover=None):
|
||||||
|
return get_cluster_initialized_without_leader(leader=True, failover=failover)
|
||||||
|
|
||||||
|
def get_cluster_initialized_with_only_leader(failover=None):
|
||||||
|
l = get_cluster_initialized_without_leader(leader=True, failover=failover).leader
|
||||||
|
return get_cluster(True, l, [l], failover)
|
||||||
|
|
||||||
|
|
||||||
|
class MockPostgresql(Mock):
|
||||||
|
|
||||||
|
name = 'postgresql0'
|
||||||
|
role = 'replica'
|
||||||
|
state = 'running'
|
||||||
|
connection_string = 'postgres://foo@bar/postgres'
|
||||||
|
|
||||||
def is_healthy(self):
|
def is_healthy(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def write_recovery_conf(self, _):
|
|
||||||
return True
|
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -36,64 +61,89 @@ class MockPostgresql:
|
|||||||
def is_leader(self):
|
def is_leader(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def promote(self):
|
def xlog_position(self):
|
||||||
return True
|
return 0
|
||||||
|
|
||||||
def demote(self, _):
|
|
||||||
return True
|
|
||||||
|
|
||||||
def follow_the_leader(self, _):
|
|
||||||
return True
|
|
||||||
|
|
||||||
def create_replication_slots(self, _):
|
|
||||||
return True
|
|
||||||
|
|
||||||
def last_operation(self):
|
def last_operation(self):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
def data_directory_empty(self):
|
||||||
|
return False
|
||||||
|
|
||||||
def nop(*args, **kwargs):
|
def bootstrap(self, *args, **kwargs):
|
||||||
pass
|
return True
|
||||||
|
|
||||||
|
def check_replication_lag(self, last_leader_operation):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def check_recovery_conf(self, leader):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def dead_etcd():
|
class MockPatroni:
|
||||||
raise DCSError('Etcd is not responding properly')
|
|
||||||
|
def __init__(self, p, d):
|
||||||
|
self.postgresql = p
|
||||||
|
self.dcs = d
|
||||||
|
self.api = Mock()
|
||||||
|
self.tags = {}
|
||||||
|
self.nofailover = None
|
||||||
|
self.api.connection_string = 'http://127.0.0.1:8008'
|
||||||
|
|
||||||
|
|
||||||
def get_unlocked_cluster():
|
def run_async(func, args=()):
|
||||||
return Cluster(False, None, None, [])
|
func(*args) if args else func()
|
||||||
|
|
||||||
|
|
||||||
class TestHa(unittest.TestCase):
|
class TestHa(unittest.TestCase):
|
||||||
|
|
||||||
def __init__(self, method_name='runTest'):
|
@patch('socket.getaddrinfo', socket_getaddrinfo)
|
||||||
self.setUp = self.set_up
|
@patch.object(Client, 'machines')
|
||||||
super(TestHa, self).__init__(method_name)
|
def setUp(self, mock_machines):
|
||||||
|
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
|
||||||
def set_up(self):
|
|
||||||
self.p = MockPostgresql()
|
self.p = MockPostgresql()
|
||||||
with patch.object(Client, 'machines') as mock_machines:
|
self.e = Etcd('foo', {'ttl': 30, 'host': 'ok:2379', 'scope': 'test'})
|
||||||
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
|
self.e.client.read = etcd_read
|
||||||
self.e = Etcd('foo', {'ttl': 30, 'host': 'remotehost:2379', 'scope': 'test'})
|
self.e.client.write = etcd_write
|
||||||
self.e.client.read = etcd_read
|
self.e.client.delete = Mock(side_effect=etcd.EtcdException())
|
||||||
self.e.client.write = etcd_write
|
self.ha = Ha(MockPatroni(self.p, self.e))
|
||||||
self.ha = Ha(self.p, self.e)
|
self.ha._async_executor.run_async = run_async
|
||||||
self.ha.load_cluster_from_dcs()
|
self.ha.old_cluster = self.e.get_cluster()
|
||||||
self.ha.cluster = get_unlocked_cluster()
|
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||||
self.ha.load_cluster_from_dcs = nop
|
self.ha.load_cluster_from_dcs = Mock()
|
||||||
|
|
||||||
def test_load_cluster_from_dcs(self):
|
def test_update_lock(self):
|
||||||
ha = Ha(self.p, self.e)
|
self.p.last_operation = Mock(side_effect=PostgresException(''))
|
||||||
ha.load_cluster_from_dcs()
|
self.assertTrue(self.ha.update_lock())
|
||||||
self.e.get_cluster = get_unlocked_cluster
|
|
||||||
ha.load_cluster_from_dcs()
|
|
||||||
|
|
||||||
def test_start_as_slave(self):
|
def test_touch_member(self):
|
||||||
|
self.p.xlog_position = Mock(side_effect=Exception)
|
||||||
|
self.ha.touch_member()
|
||||||
|
|
||||||
|
def test_start_as_replica(self):
|
||||||
self.p.is_healthy = false
|
self.p.is_healthy = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'started as a secondary')
|
self.assertEquals(self.ha.run_cycle(), 'started as a secondary')
|
||||||
|
|
||||||
|
def test_recover_replica_failed(self):
|
||||||
|
self.p.controldata = lambda: {'Database cluster state': 'in production'}
|
||||||
|
self.p.is_healthy = false
|
||||||
|
self.p.follow_the_leader = false
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'failed to start postgres')
|
||||||
|
|
||||||
|
def test_recover_master_failed(self):
|
||||||
|
self.p.follow_the_leader = false
|
||||||
|
self.p.is_healthy = false
|
||||||
|
self.ha.has_lock = true
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'removed leader key after trying and failing to start postgres')
|
||||||
|
|
||||||
|
@patch('sys.exit', return_value=1)
|
||||||
|
@patch('patroni.ha.Ha.sysid_valid', MagicMock(return_value=True))
|
||||||
|
def test_sysid_no_match(self, exit_mock):
|
||||||
|
self.ha.run_cycle()
|
||||||
|
exit_mock.assert_called_once_with(1)
|
||||||
|
|
||||||
|
@patch.object(Cluster, 'is_unlocked', Mock(return_value=False))
|
||||||
def test_start_as_readonly(self):
|
def test_start_as_readonly(self):
|
||||||
self.ha.cluster.is_unlocked = false
|
|
||||||
self.p.is_leader = self.p.is_healthy = false
|
self.p.is_leader = self.p.is_healthy = false
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader because i had the session lock')
|
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader because i had the session lock')
|
||||||
@@ -102,6 +152,7 @@ class TestHa(unittest.TestCase):
|
|||||||
self.assertEquals(self.ha.run_cycle(), 'acquired session lock as a leader')
|
self.assertEquals(self.ha.run_cycle(), 'acquired session lock as a leader')
|
||||||
|
|
||||||
def test_promoted_by_acquiring_lock(self):
|
def test_promoted_by_acquiring_lock(self):
|
||||||
|
self.ha.is_healthiest_node = true
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||||
|
|
||||||
@@ -110,16 +161,17 @@ class TestHa(unittest.TestCase):
|
|||||||
self.assertEquals(self.ha.run_cycle(), 'demoted self due after trying and failing to obtain lock')
|
self.assertEquals(self.ha.run_cycle(), 'demoted self due after trying and failing to obtain lock')
|
||||||
|
|
||||||
def test_follow_new_leader_after_failing_to_obtain_lock(self):
|
def test_follow_new_leader_after_failing_to_obtain_lock(self):
|
||||||
|
self.ha.is_healthiest_node = true
|
||||||
self.ha.acquire_lock = false
|
self.ha.acquire_lock = false
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'following new leader after trying and failing to obtain lock')
|
self.assertEquals(self.ha.run_cycle(), 'following new leader after trying and failing to obtain lock')
|
||||||
|
|
||||||
def test_demote_because_not_healthiest(self):
|
def test_demote_because_not_healthiest(self):
|
||||||
self.p.is_healthiest_node = false
|
self.ha.is_healthiest_node = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'demoting self because i am not the healthiest node')
|
self.assertEquals(self.ha.run_cycle(), 'demoting self because i am not the healthiest node')
|
||||||
|
|
||||||
def test_follow_new_leader_because_not_healthiest(self):
|
def test_follow_new_leader_because_not_healthiest(self):
|
||||||
self.p.is_healthiest_node = false
|
self.ha.is_healthiest_node = false
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
||||||
|
|
||||||
@@ -138,11 +190,146 @@ class TestHa(unittest.TestCase):
|
|||||||
self.ha.cluster.is_unlocked = false
|
self.ha.cluster.is_unlocked = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'demoting self because i do not have the lock and i was a leader')
|
self.assertEquals(self.ha.run_cycle(), 'demoting self because i do not have the lock and i was a leader')
|
||||||
|
|
||||||
|
def test_demote_because_update_lock_failed(self):
|
||||||
|
self.ha.cluster.is_unlocked = false
|
||||||
|
self.ha.has_lock = true
|
||||||
|
self.ha.update_lock = false
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'demoting self because i do not have the lock and i was a leader')
|
||||||
|
|
||||||
def test_follow_the_leader(self):
|
def test_follow_the_leader(self):
|
||||||
self.ha.cluster.is_unlocked = false
|
self.ha.cluster.is_unlocked = false
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
self.assertEquals(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
||||||
|
|
||||||
def test_no_etcd_connection_master_demote(self):
|
def test_no_etcd_connection_master_demote(self):
|
||||||
self.ha.load_cluster_from_dcs = dead_etcd
|
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
||||||
self.assertEquals(self.ha.run_cycle(), 'demoted self because DCS is not accessible and i was a leader')
|
self.assertEquals(self.ha.run_cycle(), 'demoted self because DCS is not accessible and i was a leader')
|
||||||
|
|
||||||
|
def test_bootstrap_from_leader(self):
|
||||||
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
|
self.p.bootstrap = false
|
||||||
|
self.assertEquals(self.ha.bootstrap(), 'trying to bootstrap from leader')
|
||||||
|
|
||||||
|
def test_bootstrap_waiting_for_leader(self):
|
||||||
|
self.ha.cluster = get_cluster_initialized_without_leader()
|
||||||
|
self.assertEquals(self.ha.bootstrap(), 'waiting for leader to bootstrap')
|
||||||
|
|
||||||
|
def test_bootstrap_initialize_lock_failed(self):
|
||||||
|
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||||
|
self.assertEquals(self.ha.bootstrap(), 'failed to acquire initialize lock')
|
||||||
|
|
||||||
|
def test_bootstrap_initialized_new_cluster(self):
|
||||||
|
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||||
|
self.e.initialize = true
|
||||||
|
self.assertEquals(self.ha.bootstrap(), 'initialized a new cluster')
|
||||||
|
|
||||||
|
def test_bootstrap_release_initialize_key_on_failure(self):
|
||||||
|
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||||
|
self.e.initialize = true
|
||||||
|
self.p.bootstrap = Mock(side_effect=PostgresException("Could not bootstrap master PostgreSQL"))
|
||||||
|
self.assertRaises(PostgresException, self.ha.bootstrap)
|
||||||
|
|
||||||
|
def test_reinitialize(self):
|
||||||
|
self.ha.schedule_reinitialize()
|
||||||
|
self.ha.schedule_reinitialize()
|
||||||
|
self.ha.run_cycle()
|
||||||
|
self.assertIsNone(self.ha._async_executor.scheduled_action)
|
||||||
|
|
||||||
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
|
self.ha.has_lock = true
|
||||||
|
self.ha.schedule_reinitialize()
|
||||||
|
self.ha.run_cycle()
|
||||||
|
self.assertIsNone(self.ha._async_executor.scheduled_action)
|
||||||
|
|
||||||
|
self.ha.has_lock = false
|
||||||
|
self.ha.schedule_reinitialize()
|
||||||
|
self.ha.run_cycle()
|
||||||
|
|
||||||
|
def test_restart(self):
|
||||||
|
self.assertEquals(self.ha.restart(), (True, 'restarted successfully'))
|
||||||
|
self.p.restart = false
|
||||||
|
self.assertEquals(self.ha.restart(), (False, 'restart failed'))
|
||||||
|
self.ha.schedule_reinitialize()
|
||||||
|
self.assertEquals(self.ha.restart(), (False, 'reinitialize already in progress'))
|
||||||
|
|
||||||
|
def test_restart_in_progress(self):
|
||||||
|
self.ha._async_executor.schedule('restart', True)
|
||||||
|
self.assertTrue(self.ha.restart_scheduled())
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'not healthy enough for leader race')
|
||||||
|
|
||||||
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'restart in progress')
|
||||||
|
|
||||||
|
self.ha.has_lock = true
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'updated leader lock during restart')
|
||||||
|
|
||||||
|
self.ha.update_lock = false
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'failed to update leader lock during restart')
|
||||||
|
|
||||||
|
@patch('requests.get', requests_get)
|
||||||
|
def test_manual_failover_from_leader(self):
|
||||||
|
self.ha.has_lock = true
|
||||||
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', ''))
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', MockPostgresql.name))
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla'))
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
|
f = Failover(0, MockPostgresql.name, '')
|
||||||
|
self.ha.cluster = get_cluster_initialized_with_leader(f)
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'manual failover: demoting myself')
|
||||||
|
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {'nofailover': 'True'})
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
|
# manual failover from the previous leader to us won't happen if we hold the nofailover flag
|
||||||
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', MockPostgresql.name))
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
|
|
||||||
|
@patch('requests.get', requests_get)
|
||||||
|
def test_manual_failover_process_no_leader(self):
|
||||||
|
self.p.is_leader = false
|
||||||
|
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', MockPostgresql.name))
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||||
|
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'leader'))
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||||
|
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {}) # accessible, in_recovery
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
||||||
|
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, MockPostgresql.name, ''))
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
||||||
|
self.ha.fetch_node_status = lambda e: (e, False, True, 0, {}) # inaccessible, in_recovery
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||||
|
# set failover flag to True for all members of the cluster
|
||||||
|
# this should elect the current member, as we are not going to call the API for it.
|
||||||
|
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other'))
|
||||||
|
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {'nofailover': 'True'}) # accessible, in_recovery
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||||
|
# same as previous, but set the current member to nofailover. In no case it should be elected as a leader
|
||||||
|
self.ha.patroni.nofailover = True
|
||||||
|
self.assertEquals(self.ha.run_cycle(), 'following a different leader because I am not allowed to promote')
|
||||||
|
|
||||||
|
def test_is_healthiest_node(self):
|
||||||
|
self.ha.state_handler.is_leader = false
|
||||||
|
self.ha.patroni.nofailover = False
|
||||||
|
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {})
|
||||||
|
self.assertTrue(self.ha.is_healthiest_node())
|
||||||
|
|
||||||
|
def test__is_healthiest_node(self):
|
||||||
|
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
|
self.p.is_leader = false
|
||||||
|
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {}) # accessible, in_recovery
|
||||||
|
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
|
self.ha.fetch_node_status = lambda e: (e, True, False, 0, {}) # accessible, not in_recovery
|
||||||
|
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
|
self.ha.fetch_node_status = lambda e: (e, True, True, 1, {}) # accessible, in_recovery, xlog location ahead
|
||||||
|
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
|
self.p.check_replication_lag = false
|
||||||
|
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
|
self.ha.patroni.nofailover = True
|
||||||
|
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
|
self.ha.patroni.nofailover = False
|
||||||
|
|
||||||
|
@patch('requests.get', requests_get)
|
||||||
|
def test_fetch_node_status(self):
|
||||||
|
member = Member(0, 'test', 1, {'api_url': 'http://127.0.0.1:8011/patroni'})
|
||||||
|
self.ha.fetch_node_status(member)
|
||||||
|
member = Member(0, 'test', 1, {'api_url': 'http://localhost:8011/patroni'})
|
||||||
|
self.ha.fetch_node_status(member)
|
||||||
|
|||||||
+40
-107
@@ -1,7 +1,3 @@
|
|||||||
import datetime
|
|
||||||
import patroni.zookeeper
|
|
||||||
import psycopg2
|
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
@@ -9,141 +5,78 @@ import yaml
|
|||||||
|
|
||||||
from mock import Mock, patch
|
from mock import Mock, patch
|
||||||
from patroni.api import RestApiServer
|
from patroni.api import RestApiServer
|
||||||
from patroni.dcs import Cluster, Member
|
from patroni.async_executor import AsyncExecutor
|
||||||
from patroni.etcd import Etcd
|
from patroni.etcd import Etcd
|
||||||
from patroni import Patroni, main
|
from patroni import Patroni, main
|
||||||
from patroni.zookeeper import ZooKeeper
|
from patroni.zookeeper import ZooKeeper
|
||||||
from six.moves import BaseHTTPServer
|
from six.moves import BaseHTTPServer
|
||||||
from test_etcd import Client, etcd_read, etcd_write
|
from test_etcd import Client, SleepException, etcd_read, etcd_write
|
||||||
from test_ha import true, false
|
from test_postgresql import Postgresql, psycopg2_connect
|
||||||
from test_postgresql import Postgresql, subprocess_call, psycopg2_connect
|
|
||||||
from test_zookeeper import MockKazooClient
|
from test_zookeeper import MockKazooClient
|
||||||
|
|
||||||
|
|
||||||
def nop(*args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class SleepException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def time_sleep(*args):
|
def time_sleep(*args):
|
||||||
raise SleepException()
|
raise SleepException()
|
||||||
|
|
||||||
|
|
||||||
class Mock_BaseServer__is_shut_down:
|
@patch('time.sleep', Mock())
|
||||||
|
@patch('subprocess.call', Mock(return_value=0))
|
||||||
def set(self):
|
@patch('psycopg2.connect', psycopg2_connect)
|
||||||
pass
|
@patch.object(Postgresql, 'write_pg_hba', Mock())
|
||||||
|
@patch.object(Postgresql, 'write_recovery_conf', Mock())
|
||||||
def clear(self):
|
@patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock())
|
||||||
pass
|
@patch.object(AsyncExecutor, 'run', Mock())
|
||||||
|
|
||||||
|
|
||||||
class TestPatroni(unittest.TestCase):
|
class TestPatroni(unittest.TestCase):
|
||||||
|
|
||||||
def __init__(self, method_name='runTest'):
|
@patch.object(Client, 'machines')
|
||||||
self.setUp = self.set_up
|
def setUp(self, mock_machines):
|
||||||
self.tearDown = self.tear_down
|
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
|
||||||
super(TestPatroni, self).__init__(method_name)
|
|
||||||
|
|
||||||
def set_up(self):
|
|
||||||
self.touched = False
|
self.touched = False
|
||||||
subprocess.call = subprocess_call
|
self.init_cancelled = False
|
||||||
psycopg2.connect = psycopg2_connect
|
RestApiServer._BaseServer__is_shut_down = Mock()
|
||||||
self.time_sleep = time.sleep
|
|
||||||
time.sleep = nop
|
|
||||||
self.write_pg_hba = Postgresql.write_pg_hba
|
|
||||||
self.write_recovery_conf = Postgresql.write_recovery_conf
|
|
||||||
Postgresql.write_pg_hba = nop
|
|
||||||
Postgresql.write_recovery_conf = nop
|
|
||||||
BaseHTTPServer.HTTPServer.__init__ = nop
|
|
||||||
RestApiServer._BaseServer__is_shut_down = Mock_BaseServer__is_shut_down()
|
|
||||||
RestApiServer._BaseServer__shutdown_request = True
|
RestApiServer._BaseServer__shutdown_request = True
|
||||||
RestApiServer.socket = 0
|
RestApiServer.socket = 0
|
||||||
with open('postgres0.yml', 'r') as f:
|
with open('postgres0.yml', 'r') as f:
|
||||||
config = yaml.load(f)
|
config = yaml.load(f)
|
||||||
with patch.object(Client, 'machines') as mock_machines:
|
self.p = Patroni(config)
|
||||||
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
|
self.p.ha.dcs.client.write = etcd_write
|
||||||
self.p = Patroni(config)
|
self.p.ha.dcs.client.read = etcd_read
|
||||||
|
|
||||||
def tear_down(self):
|
|
||||||
time.sleep = self.time_sleep
|
|
||||||
Postgresql.write_pg_hba = self.write_pg_hba
|
|
||||||
Postgresql.write_recovery_conf = self.write_recovery_conf
|
|
||||||
|
|
||||||
|
@patch('patroni.zookeeper.KazooClient', MockKazooClient())
|
||||||
def test_get_dcs(self):
|
def test_get_dcs(self):
|
||||||
patroni.zookeeper.KazooClient = MockKazooClient
|
|
||||||
self.assertIsInstance(self.p.get_dcs('', {'zookeeper': {'scope': '', 'hosts': ''}}), ZooKeeper)
|
self.assertIsInstance(self.p.get_dcs('', {'zookeeper': {'scope': '', 'hosts': ''}}), ZooKeeper)
|
||||||
self.assertRaises(Exception, self.p.get_dcs, '', {})
|
self.assertRaises(Exception, self.p.get_dcs, '', {})
|
||||||
|
|
||||||
def test_patroni_main(self):
|
@patch('time.sleep', Mock(side_effect=SleepException()))
|
||||||
|
@patch.object(Etcd, 'delete_leader', Mock())
|
||||||
|
@patch.object(Client, 'machines')
|
||||||
|
def test_patroni_main(self, mock_machines):
|
||||||
main()
|
main()
|
||||||
sys.argv = ['patroni.py', 'postgres0.yml']
|
sys.argv = ['patroni.py', 'postgres0.yml']
|
||||||
time.sleep = time_sleep
|
|
||||||
|
|
||||||
with patch.object(Client, 'machines') as mock_machines:
|
|
||||||
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
|
|
||||||
Patroni.initialize = nop
|
|
||||||
touch_member = Patroni.touch_member
|
|
||||||
run = Patroni.run
|
|
||||||
|
|
||||||
Patroni.touch_member = self.touch_member
|
|
||||||
Patroni.run = time_sleep
|
|
||||||
|
|
||||||
Etcd.delete_leader = nop
|
|
||||||
|
|
||||||
|
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
|
||||||
|
with patch.object(Patroni, 'run', Mock(side_effect=SleepException())):
|
||||||
self.assertRaises(SleepException, main)
|
self.assertRaises(SleepException, main)
|
||||||
|
with patch.object(Patroni, 'run', Mock(side_effect=KeyboardInterrupt())):
|
||||||
|
main()
|
||||||
|
|
||||||
Patroni.run = run
|
@patch('time.sleep', Mock(side_effect=SleepException()))
|
||||||
Patroni.touch_member = touch_member
|
def test_run(self):
|
||||||
|
|
||||||
def test_patroni_run(self):
|
|
||||||
time.sleep = time_sleep
|
|
||||||
self.p.touch_member = self.touch_member
|
|
||||||
self.p.ha.state_handler.sync_replication_slots = time_sleep
|
|
||||||
self.p.ha.dcs.client.read = etcd_read
|
|
||||||
self.p.ha.dcs.watch = time_sleep
|
self.p.ha.dcs.watch = time_sleep
|
||||||
self.assertRaises(SleepException, self.p.run)
|
self.assertRaises(SleepException, self.p.run)
|
||||||
self.p.ha.state_handler.is_leader = lambda: False
|
|
||||||
self.p.api.start = nop
|
self.p.ha.state_handler.is_leader = Mock(return_value=False)
|
||||||
|
self.p.api.start = Mock()
|
||||||
self.assertRaises(SleepException, self.p.run)
|
self.assertRaises(SleepException, self.p.run)
|
||||||
|
|
||||||
def touch_member(self, ttl=None):
|
|
||||||
if not self.touched:
|
|
||||||
self.touched = True
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def test_touch_member(self):
|
|
||||||
self.p.ha.dcs.client.write = etcd_write
|
|
||||||
self.p.touch_member()
|
|
||||||
now = datetime.datetime.utcnow()
|
|
||||||
member = Member(0, self.p.postgresql.name, 'b', 'c', (now + datetime.timedelta(
|
|
||||||
seconds=self.p.shutdown_member_ttl + 10)).strftime('%Y-%m-%dT%H:%M:%S.%fZ'), None)
|
|
||||||
self.p.ha.cluster = Cluster(True, member, 0, [member])
|
|
||||||
self.p.touch_member()
|
|
||||||
|
|
||||||
def test_patroni_initialize(self):
|
|
||||||
self.p.postgresql.should_use_s3_to_create_replica = false
|
|
||||||
self.p.ha.dcs.client.write = etcd_write
|
|
||||||
self.p.touch_member = self.touch_member
|
|
||||||
self.p.postgresql.data_directory_empty = true
|
|
||||||
self.p.ha.dcs.race = true
|
|
||||||
self.p.initialize()
|
|
||||||
|
|
||||||
self.p.ha.dcs.race = false
|
|
||||||
time.sleep = time_sleep
|
|
||||||
self.p.ha.dcs.client.read = etcd_read
|
|
||||||
self.p.initialize()
|
|
||||||
|
|
||||||
self.p.ha.dcs.current_leader = nop
|
|
||||||
self.assertRaises(Exception, self.p.initialize)
|
|
||||||
|
|
||||||
self.p.postgresql.data_directory_empty = false
|
|
||||||
self.p.initialize()
|
|
||||||
|
|
||||||
def test_schedule_next_run(self):
|
def test_schedule_next_run(self):
|
||||||
|
self.p.ha.dcs.watch = Mock(return_value=True)
|
||||||
|
self.p.schedule_next_run()
|
||||||
self.p.next_run = time.time() - self.p.nap_time - 1
|
self.p.next_run = time.time() - self.p.nap_time - 1
|
||||||
self.p.schedule_next_run()
|
self.p.schedule_next_run()
|
||||||
|
|
||||||
|
def test_nofailover(self):
|
||||||
|
self.p.tags['nofailover'] = True
|
||||||
|
self.assertTrue(self.p.nofailover)
|
||||||
|
self.p.tags['nofailover'] = None
|
||||||
|
self.assertFalse(self.p.nofailover)
|
||||||
|
|||||||
+356
-93
@@ -1,50 +1,41 @@
|
|||||||
|
import mock # for the mock.call method, importing it without a namespace breaks python3
|
||||||
import os
|
import os
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from six.moves import builtins
|
||||||
|
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
|
||||||
from patroni.dcs import Cluster, Leader, Member
|
from patroni.dcs import Cluster, Leader, Member
|
||||||
|
from patroni.exceptions import PostgresException, PostgresConnectionException
|
||||||
from patroni.postgresql import Postgresql
|
from patroni.postgresql import Postgresql
|
||||||
|
from patroni.utils import RetryFailedError
|
||||||
|
from test_ha import false
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
def nop(*args, **kwargs):
|
def is_file_raise_on_backup(*args, **kwargs):
|
||||||
pass
|
if args[0].endswith('.backup'):
|
||||||
|
raise Exception("foo")
|
||||||
|
|
||||||
def subprocess_call(cmd, shell=False, env=None):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def false(*args, **kwargs):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class MockCursor:
|
class MockCursor:
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, connection):
|
||||||
|
self.connection = connection
|
||||||
self.closed = False
|
self.closed = False
|
||||||
self.results = []
|
self.results = []
|
||||||
|
|
||||||
def execute(self, sql, *params):
|
def execute(self, sql, *params):
|
||||||
if sql.startswith('blabla'):
|
if sql.startswith('blabla') or sql == 'CHECKPOINT':
|
||||||
raise psycopg2.OperationalError()
|
raise psycopg2.OperationalError()
|
||||||
elif sql.startswith('InterfaceError'):
|
elif sql.startswith('RetryFailedError'):
|
||||||
raise psycopg2.InterfaceError()
|
raise RetryFailedError('retry')
|
||||||
elif sql.startswith('SELECT slot_name'):
|
elif sql.startswith('SELECT slot_name'):
|
||||||
self.results = [('blabla',), ('foobar',)]
|
self.results = [('blabla',), ('foobar',)]
|
||||||
elif sql.startswith('SELECT pg_current_xlog_location()'):
|
|
||||||
self.results = [(0,)]
|
|
||||||
elif sql.startswith('SELECT pg_is_in_recovery(), %s'):
|
|
||||||
if params[0][0] == 1:
|
|
||||||
raise psycopg2.OperationalError()
|
|
||||||
elif params[0][0] == 2:
|
|
||||||
self.results = [(True, -1)]
|
|
||||||
else:
|
|
||||||
self.results = [(False, 0)]
|
|
||||||
elif sql.startswith('SELECT pg_xlog_location_diff'):
|
elif sql.startswith('SELECT pg_xlog_location_diff'):
|
||||||
self.results = [(0,)]
|
self.results = [(0,)]
|
||||||
elif sql.startswith('SELECT pg_is_in_recovery()'):
|
elif sql == 'SELECT pg_is_in_recovery()':
|
||||||
self.results = [(False, )]
|
self.results = [(False, )]
|
||||||
elif sql.startswith('SELECT to_char(pg_postmaster_start_time'):
|
elif sql.startswith('SELECT to_char(pg_postmaster_start_time'):
|
||||||
self.results = [('', True, '', '', '', False)]
|
self.results = [('', True, '', '', '', False)]
|
||||||
@@ -65,6 +56,9 @@ class MockCursor:
|
|||||||
def fetchone(self):
|
def fetchone(self):
|
||||||
return self.results[0]
|
return self.results[0]
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
return self.results
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -72,44 +66,108 @@ class MockCursor:
|
|||||||
for i in self.results:
|
for i in self.results:
|
||||||
yield i
|
yield i
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
class MockConnect:
|
def __exit__(self, *args):
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.autocommit = False
|
|
||||||
self.closed = 0
|
|
||||||
|
|
||||||
def cursor(self):
|
|
||||||
return MockCursor()
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def psycopg2_connect(*args, **kwargs):
|
class MockConnect(Mock):
|
||||||
|
|
||||||
|
autocommit = False
|
||||||
|
closed = 0
|
||||||
|
|
||||||
|
def cursor(self):
|
||||||
|
return MockCursor(self)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def pg_controldata_string(*args, **kwargs):
|
||||||
|
return b"""
|
||||||
|
pg_control version number: 942
|
||||||
|
Catalog version number: 201509161
|
||||||
|
Database system identifier: 6200971513092291716
|
||||||
|
Database cluster state: shut down in recovery
|
||||||
|
pg_control last modified: Fri Oct 2 10:57:06 2015
|
||||||
|
Latest checkpoint location: 0/30000C8
|
||||||
|
Prior checkpoint location: 0/2000060
|
||||||
|
Latest checkpoint's REDO location: 0/3000090
|
||||||
|
Latest checkpoint's REDO WAL file: 000000020000000000000003
|
||||||
|
Latest checkpoint's TimeLineID: 2
|
||||||
|
Latest checkpoint's PrevTimeLineID: 2
|
||||||
|
Latest checkpoint's full_page_writes: on
|
||||||
|
Latest checkpoint's NextXID: 0/943
|
||||||
|
Latest checkpoint's NextOID: 24576
|
||||||
|
Latest checkpoint's NextMultiXactId: 1
|
||||||
|
Latest checkpoint's NextMultiOffset: 0
|
||||||
|
Latest checkpoint's oldestXID: 931
|
||||||
|
Latest checkpoint's oldestXID's DB: 1
|
||||||
|
Latest checkpoint's oldestActiveXID: 943
|
||||||
|
Latest checkpoint's oldestMultiXid: 1
|
||||||
|
Latest checkpoint's oldestMulti's DB: 1
|
||||||
|
Latest checkpoint's oldestCommitTs: 0
|
||||||
|
Latest checkpoint's newestCommitTs: 0
|
||||||
|
Time of latest checkpoint: Fri Oct 2 10:56:54 2015
|
||||||
|
Fake LSN counter for unlogged rels: 0/1
|
||||||
|
Minimum recovery ending location: 0/30241F8
|
||||||
|
Min recovery ending loc's timeline: 2
|
||||||
|
Backup start location: 0/0
|
||||||
|
Backup end location: 0/0
|
||||||
|
End-of-backup record required: no
|
||||||
|
wal_level setting: hot_standby
|
||||||
|
Current wal_log_hints setting: on
|
||||||
|
Current max_connections setting: 100
|
||||||
|
Current max_worker_processes setting: 8
|
||||||
|
Current max_prepared_xacts setting: 0
|
||||||
|
Current max_locks_per_xact setting: 64
|
||||||
|
Current track_commit_timestamp setting: off
|
||||||
|
Maximum data alignment: 8
|
||||||
|
Database block size: 8192
|
||||||
|
Blocks per segment of large relation: 131072
|
||||||
|
WAL block size: 8192
|
||||||
|
Bytes per WAL segment: 16777216
|
||||||
|
Maximum length of identifiers: 64
|
||||||
|
Maximum columns in an index: 32
|
||||||
|
Maximum size of a TOAST chunk: 1996
|
||||||
|
Size of a large-object chunk: 2048
|
||||||
|
Date/time type storage: 64-bit integers
|
||||||
|
Float4 argument passing: by value
|
||||||
|
Float8 argument passing: by value
|
||||||
|
Data page checksum version: 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def postmaster_opts_string(*args, **kwargs):
|
||||||
|
return '/usr/local/pgsql/bin/postgres "-D" "data/postgresql0" "--listen_addresses=127.0.0.1" \
|
||||||
|
"--port=5432" "--hot_standby=on" "--wal_keep_segments=8" "--wal_level=hot_standby" \
|
||||||
|
"--archive_command=mkdir -p ../wal_archive && cp %p ../wal_archive/%f" "--wal_log_hints=on" \
|
||||||
|
"--max_wal_senders=5" "--archive_timeout=1800s" "--archive_mode=on" "--max_replication_slots=5"\n'
|
||||||
|
|
||||||
|
|
||||||
|
def psycopg2_connect(*args, **kwargs):
|
||||||
return MockConnect()
|
return MockConnect()
|
||||||
|
|
||||||
|
|
||||||
def is_running():
|
@patch('subprocess.call', Mock(return_value=0))
|
||||||
return False
|
@patch('psycopg2.connect', psycopg2_connect)
|
||||||
|
@patch('shutil.copy', Mock())
|
||||||
|
|
||||||
class TestPostgresql(unittest.TestCase):
|
class TestPostgresql(unittest.TestCase):
|
||||||
|
|
||||||
def __init__(self, method_name='runTest'):
|
@patch('subprocess.call', Mock(return_value=0))
|
||||||
self.setUp = self.set_up
|
@patch('psycopg2.connect', psycopg2_connect)
|
||||||
self.tearDown = self.tear_down
|
def setUp(self):
|
||||||
super(TestPostgresql, self).__init__(method_name)
|
|
||||||
|
|
||||||
def set_up(self):
|
|
||||||
subprocess.call = subprocess_call
|
|
||||||
shutil.copy = nop
|
|
||||||
self.p = Postgresql({'name': 'test0', 'scope': 'batman', 'data_dir': 'data/test0',
|
self.p = Postgresql({'name': 'test0', 'scope': 'batman', 'data_dir': 'data/test0',
|
||||||
'listen': '127.0.0.1, *:5432', 'connect_address': '127.0.0.2:5432',
|
'listen': '127.0.0.1, *:5432', 'connect_address': '127.0.0.2:5432',
|
||||||
'pg_hba': ['hostssl all all 0.0.0.0/0 md5', 'host all all 0.0.0.0/0 md5'],
|
'pg_hba': ['hostssl all all 0.0.0.0/0 md5', 'host all all 0.0.0.0/0 md5'],
|
||||||
'superuser': {'password': ''},
|
'superuser': {'password': 'test'},
|
||||||
'admin': {'username': 'admin', 'password': 'admin'},
|
'admin': {'username': 'admin', 'password': 'admin'},
|
||||||
|
'pg_rewind': {'username': 'admin', 'password': 'admin'},
|
||||||
'replication': {'username': 'replicator',
|
'replication': {'username': 'replicator',
|
||||||
'password': 'rep-pass',
|
'password': 'rep-pass',
|
||||||
'network': '127.0.0.1/32'},
|
'network': '127.0.0.1/32'},
|
||||||
@@ -119,44 +177,122 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
'on_reload': 'true'
|
'on_reload': 'true'
|
||||||
},
|
},
|
||||||
'restore': 'true'})
|
'restore': 'true'})
|
||||||
psycopg2.connect = psycopg2_connect
|
|
||||||
if not os.path.exists(self.p.data_dir):
|
if not os.path.exists(self.p.data_dir):
|
||||||
os.makedirs(self.p.data_dir)
|
os.makedirs(self.p.data_dir)
|
||||||
self.leadermem = Member(0, 'leader', 'postgres://replicator:[email protected]:5435/postgres', None, None, 28)
|
self.leadermem = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres'})
|
||||||
self.leader = Leader(-1, None, 28, self.leadermem)
|
self.leader = Leader(-1, 28, self.leadermem)
|
||||||
self.other = Member(0, 'test1', 'postgres://replicator:[email protected]:5433/postgres', None, None, 28)
|
self.other = Member(0, 'test1', 28, {'conn_url': 'postgres://replicator:[email protected]:5433/postgres'})
|
||||||
self.me = Member(0, 'test0', 'postgres://replicator:[email protected]:5434/postgres', None, None, 28)
|
self.me = Member(0, 'test0', 28, {'conn_url': 'postgres://replicator:[email protected]:5434/postgres'})
|
||||||
|
|
||||||
def tear_down(self):
|
def tearDown(self):
|
||||||
shutil.rmtree('data')
|
shutil.rmtree('data')
|
||||||
|
|
||||||
def mock_query(self, p):
|
|
||||||
raise psycopg2.OperationalError("not supported")
|
|
||||||
|
|
||||||
def test_data_directory_empty(self):
|
def test_data_directory_empty(self):
|
||||||
self.assertTrue(self.p.data_directory_empty())
|
self.assertTrue(self.p.data_directory_empty())
|
||||||
|
|
||||||
|
def test_get_initdb_options(self):
|
||||||
|
self.p.initdb_options = [{'encoding': 'UTF8'}, 'data-checksums']
|
||||||
|
self.assertEquals(self.p.get_initdb_options(), ['--encoding=UTF8', '--data-checksums'])
|
||||||
|
self.p.initdb_options = [{'pgdata': 'bar'}]
|
||||||
|
self.assertRaises(Exception, self.p.get_initdb_options)
|
||||||
|
self.p.initdb_options = [{'foo': 'bar', 1: 2}]
|
||||||
|
self.assertRaises(Exception, self.p.get_initdb_options)
|
||||||
|
self.p.initdb_options = [1]
|
||||||
|
self.assertRaises(Exception, self.p.get_initdb_options)
|
||||||
|
|
||||||
def test_initialize(self):
|
def test_initialize(self):
|
||||||
self.assertTrue(self.p.initialize())
|
self.assertTrue(self.p.initialize())
|
||||||
self.assertTrue(os.path.exists(os.path.join(self.p.data_dir, 'pg_hba.conf')))
|
self.assertTrue(os.path.exists(os.path.join(self.p.data_dir, 'pg_hba.conf')))
|
||||||
|
|
||||||
def test_start_stop(self):
|
def test_start(self):
|
||||||
self.assertFalse(self.p.start())
|
self.assertTrue(self.p.start())
|
||||||
self.p.is_running = is_running
|
self.p.is_running = false
|
||||||
with open(os.path.join(self.p.data_dir, 'postmaster.pid'), 'w'):
|
open(os.path.join(self.p.data_dir, 'postmaster.pid'), 'w').close()
|
||||||
pass
|
|
||||||
self.assertTrue(self.p.start())
|
self.assertTrue(self.p.start())
|
||||||
self.assertTrue(self.p.stop())
|
|
||||||
|
|
||||||
|
def test_stop(self):
|
||||||
|
self.assertTrue(self.p.stop())
|
||||||
|
with patch('subprocess.call', Mock(return_value=1)):
|
||||||
|
self.assertTrue(self.p.stop())
|
||||||
|
self.p.is_running = Mock(return_value=True)
|
||||||
|
self.assertFalse(self.p.stop())
|
||||||
|
|
||||||
|
def test_restart(self):
|
||||||
|
self.p.start = false
|
||||||
|
self.p.is_running = false
|
||||||
|
self.assertFalse(self.p.restart())
|
||||||
|
self.assertEquals(self.p.state, 'restart failed (restarting)')
|
||||||
|
|
||||||
|
@patch.object(builtins, 'open', MagicMock())
|
||||||
|
def test_write_pgpass(self):
|
||||||
|
self.p.write_pgpass({'host': 'localhost', 'port': '5432', 'user': 'foo', 'password': 'bar'})
|
||||||
|
|
||||||
|
@patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
|
||||||
def test_sync_from_leader(self):
|
def test_sync_from_leader(self):
|
||||||
self.assertTrue(self.p.sync_from_leader(self.leader))
|
self.assertTrue(self.p.sync_from_leader(self.leader))
|
||||||
|
|
||||||
def test_follow_the_leader(self):
|
@patch('subprocess.call', side_effect=Exception("Test"))
|
||||||
self.p.demote(self.leader)
|
@patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
|
||||||
|
def test_pg_rewind(self, mock_call):
|
||||||
|
self.assertTrue(self.p.rewind(self.leader))
|
||||||
|
subprocess.call = mock_call
|
||||||
|
self.assertFalse(self.p.rewind(self.leader))
|
||||||
|
|
||||||
|
@patch('patroni.postgresql.Postgresql.rewind', return_value=False)
|
||||||
|
@patch('patroni.postgresql.Postgresql.remove_data_directory', MagicMock(return_value=True))
|
||||||
|
@patch('patroni.postgresql.Postgresql.single_user_mode', MagicMock(return_value=1))
|
||||||
|
@patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
|
||||||
|
def test_follow_the_leader(self, mock_pg_rewind):
|
||||||
|
self.p.demote()
|
||||||
self.p.follow_the_leader(None)
|
self.p.follow_the_leader(None)
|
||||||
self.p.demote(self.leader)
|
self.p.demote()
|
||||||
self.p.follow_the_leader(self.leader)
|
self.p.follow_the_leader(self.leader)
|
||||||
self.p.follow_the_leader(Leader(-1, None, 28, self.other))
|
self.p.follow_the_leader(Leader(-1, 28, self.other))
|
||||||
|
self.p.rewind = mock_pg_rewind
|
||||||
|
self.p.follow_the_leader(self.leader)
|
||||||
|
self.p.require_rewind()
|
||||||
|
with mock.patch('os.path.islink', MagicMock(return_value=True)):
|
||||||
|
with mock.patch('patroni.postgresql.Postgresql.can_rewind', new_callable=PropertyMock(return_value=True)):
|
||||||
|
with mock.patch('os.unlink', MagicMock(return_value=True)):
|
||||||
|
self.p.follow_the_leader(self.leader, recovery=True)
|
||||||
|
self.p.require_rewind()
|
||||||
|
with mock.patch('patroni.postgresql.Postgresql.can_rewind', new_callable=PropertyMock(return_value=True)):
|
||||||
|
self.p.rewind.return_value = True
|
||||||
|
self.p.follow_the_leader(self.leader, recovery=True)
|
||||||
|
self.p.rewind.return_value = False
|
||||||
|
self.p.follow_the_leader(self.leader, recovery=True)
|
||||||
|
|
||||||
|
def test_can_rewind(self):
|
||||||
|
tmp = self.p.pg_rewind
|
||||||
|
self.p.pg_rewind = None
|
||||||
|
self.assertFalse(self.p.can_rewind)
|
||||||
|
self.p.pg_rewind = tmp
|
||||||
|
with mock.patch('subprocess.call', MagicMock(return_value=1)):
|
||||||
|
self.assertFalse(self.p.can_rewind)
|
||||||
|
with mock.patch('subprocess.call', side_effect=OSError("foo")):
|
||||||
|
self.assertFalse(self.p.can_rewind)
|
||||||
|
tmp = self.p.controldata()
|
||||||
|
self.p.controldata = lambda: {'wal_log_hints setting': 'on'}
|
||||||
|
self.assertTrue(self.p.can_rewind)
|
||||||
|
self.p.controldata = tmp
|
||||||
|
|
||||||
|
@patch('time.sleep', Mock())
|
||||||
|
def test_create_replica(self):
|
||||||
|
self.p.delete_trigger_file = Mock(side_effect=OSError())
|
||||||
|
with patch('subprocess.call', Mock(side_effect=[1, 0])):
|
||||||
|
self.assertEquals(self.p.create_replica(self.leader, ''), 0)
|
||||||
|
with patch('subprocess.call', Mock(side_effect=[Exception(), 0])):
|
||||||
|
self.assertEquals(self.p.create_replica(self.leader, ''), 0)
|
||||||
|
|
||||||
|
self.p.config['create_replica_method'] = ['wale', 'basebackup']
|
||||||
|
self.p.config['wale'] = {'command': 'foo'}
|
||||||
|
with patch('subprocess.call', Mock(return_value=0)):
|
||||||
|
self.assertEquals(self.p.create_replica(self.leader, ''), 0)
|
||||||
|
del self.p.config['wale']
|
||||||
|
self.assertEquals(self.p.create_replica(self.leader, ''), 0)
|
||||||
|
|
||||||
|
with patch('subprocess.call', Mock(side_effect=Exception("foo"))):
|
||||||
|
self.assertEquals(self.p.create_replica(self.leader, ''), 1)
|
||||||
|
|
||||||
def test_create_connection_users(self):
|
def test_create_connection_users(self):
|
||||||
cfg = self.p.config
|
cfg = self.p.config
|
||||||
@@ -164,56 +300,183 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
p = Postgresql(cfg)
|
p = Postgresql(cfg)
|
||||||
p.create_connection_users()
|
p.create_connection_users()
|
||||||
|
|
||||||
def test_create_replication_slots(self):
|
def test_sync_replication_slots(self):
|
||||||
self.p.start()
|
self.p.start()
|
||||||
cluster = Cluster(True, self.leader, 0, [self.me, self.other, self.leadermem])
|
cluster = Cluster(True, self.leader, 0, [self.me, self.other, self.leadermem], None)
|
||||||
self.p.create_replication_slots(cluster)
|
self.p.sync_replication_slots(cluster)
|
||||||
|
self.p.query = Mock(side_effect=psycopg2.OperationalError)
|
||||||
|
self.p.schedule_load_slots = True
|
||||||
|
self.p.sync_replication_slots(cluster)
|
||||||
|
|
||||||
|
@patch.object(MockConnect, 'closed', 2)
|
||||||
|
def test__query(self):
|
||||||
|
self.assertRaises(PostgresConnectionException, self.p._query, 'blabla')
|
||||||
|
self.p._state = 'restarting'
|
||||||
|
self.assertRaises(RetryFailedError, self.p._query, 'blabla')
|
||||||
|
|
||||||
def test_query(self):
|
def test_query(self):
|
||||||
self.p.query('select 1')
|
self.p.query('select 1')
|
||||||
self.assertRaises(psycopg2.InterfaceError, self.p.query, 'InterfaceError')
|
self.assertRaises(PostgresConnectionException, self.p.query, 'RetryFailedError')
|
||||||
self.assertRaises(psycopg2.OperationalError, self.p.query, 'blabla')
|
self.assertRaises(psycopg2.OperationalError, self.p.query, 'blabla')
|
||||||
self.p._connection.closed = 2
|
|
||||||
self.assertRaises(psycopg2.OperationalError, self.p.query, 'blabla')
|
|
||||||
self.p._connection.closed = 2
|
|
||||||
self.p.disconnect = false
|
|
||||||
self.assertRaises(psycopg2.OperationalError, self.p.query, 'blabla')
|
|
||||||
|
|
||||||
def test_is_healthiest_node(self):
|
|
||||||
cluster = Cluster(True, self.leader, 0, [self.me, self.other, self.leadermem])
|
|
||||||
self.assertTrue(self.p.is_healthiest_node(cluster))
|
|
||||||
self.p.is_leader = false
|
|
||||||
self.assertFalse(self.p.is_healthiest_node(cluster))
|
|
||||||
self.p.xlog_position = lambda: 1
|
|
||||||
self.assertTrue(self.p.is_healthiest_node(cluster))
|
|
||||||
self.p.xlog_position = lambda: 2
|
|
||||||
self.assertFalse(self.p.is_healthiest_node(cluster))
|
|
||||||
self.p.config['maximum_lag_on_failover'] = -3
|
|
||||||
self.assertFalse(self.p.is_healthiest_node(cluster))
|
|
||||||
|
|
||||||
def test_is_leader(self):
|
def test_is_leader(self):
|
||||||
self.p.is_promoted = True
|
|
||||||
self.assertTrue(self.p.is_leader())
|
self.assertTrue(self.p.is_leader())
|
||||||
self.assertFalse(self.p.is_promoted)
|
|
||||||
|
|
||||||
def test_reload(self):
|
def test_reload(self):
|
||||||
self.assertTrue(self.p.reload())
|
self.assertTrue(self.p.reload())
|
||||||
|
|
||||||
def test_is_healthy(self):
|
def test_is_healthy(self):
|
||||||
self.assertTrue(self.p.is_healthy())
|
self.assertTrue(self.p.is_healthy())
|
||||||
self.p.is_running = is_running
|
self.p.is_running = false
|
||||||
self.assertFalse(self.p.is_healthy())
|
self.assertFalse(self.p.is_healthy())
|
||||||
|
|
||||||
def test_promote(self):
|
def test_promote(self):
|
||||||
|
self.p._role = 'replica'
|
||||||
|
self.assertTrue(self.p.promote())
|
||||||
self.assertTrue(self.p.promote())
|
self.assertTrue(self.p.promote())
|
||||||
|
|
||||||
def test_last_operation(self):
|
def test_last_operation(self):
|
||||||
self.assertEquals(self.p.last_operation(), '0')
|
self.assertEquals(self.p.last_operation(), '0')
|
||||||
|
|
||||||
|
@patch('subprocess.Popen', Mock(side_effect=OSError()))
|
||||||
|
def test_call_nowait(self):
|
||||||
|
self.assertFalse(self.p.call_nowait('on_start'))
|
||||||
|
|
||||||
def test_non_existing_callback(self):
|
def test_non_existing_callback(self):
|
||||||
self.assertFalse(self.p.call_nowait('foobar'))
|
self.assertFalse(self.p.call_nowait('foobar'))
|
||||||
|
|
||||||
def test_is_leader_exception(self):
|
def test_is_leader_exception(self):
|
||||||
self.p.start()
|
self.p.start()
|
||||||
self.p.query = self.mock_query
|
self.p.query = Mock(side_effect=psycopg2.OperationalError("not supported"))
|
||||||
self.assertTrue(self.p.stop())
|
self.assertTrue(self.p.stop())
|
||||||
|
|
||||||
|
def test_check_replication_lag(self):
|
||||||
|
self.assertTrue(self.p.check_replication_lag(0))
|
||||||
|
|
||||||
|
@patch('os.rename', Mock())
|
||||||
|
@patch('os.path.isdir', Mock(return_value=True))
|
||||||
|
def test_move_data_directory(self):
|
||||||
|
self.p.is_running = false
|
||||||
|
self.p.move_data_directory()
|
||||||
|
with patch('os.rename', Mock(side_effect=OSError())):
|
||||||
|
self.p.move_data_directory()
|
||||||
|
|
||||||
|
@patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
|
||||||
|
def test_bootstrap(self):
|
||||||
|
with patch('subprocess.call', Mock(return_value=1)):
|
||||||
|
self.assertRaises(PostgresException, self.p.bootstrap)
|
||||||
|
self.p.bootstrap()
|
||||||
|
self.p.bootstrap(self.leader)
|
||||||
|
|
||||||
|
def test_remove_data_directory(self):
|
||||||
|
self.p.data_dir = 'data_dir'
|
||||||
|
self.p.remove_data_directory()
|
||||||
|
os.mkdir(self.p.data_dir)
|
||||||
|
self.p.remove_data_directory()
|
||||||
|
open(self.p.data_dir, 'w').close()
|
||||||
|
self.p.remove_data_directory()
|
||||||
|
os.symlink('unexisting', self.p.data_dir)
|
||||||
|
with patch('os.unlink', Mock(side_effect=Exception)):
|
||||||
|
self.p.remove_data_directory()
|
||||||
|
self.p.remove_data_directory()
|
||||||
|
|
||||||
|
@patch('subprocess.check_output', MagicMock(return_value=0, side_effect=pg_controldata_string))
|
||||||
|
@patch('subprocess.check_output', side_effect=subprocess.CalledProcessError)
|
||||||
|
@patch('subprocess.check_output', side_effect=Exception('Failed'))
|
||||||
|
def test_controldata(self, check_output_call_error, check_output_generic_exception):
|
||||||
|
data = self.p.controldata()
|
||||||
|
self.assertEquals(len(data), 50)
|
||||||
|
self.assertEquals(data['Database cluster state'], 'shut down in recovery')
|
||||||
|
self.assertEquals(data['wal_log_hints setting'], 'on')
|
||||||
|
self.assertEquals(int(data['Database block size']), 8192)
|
||||||
|
|
||||||
|
subprocess.check_output = check_output_call_error
|
||||||
|
data = self.p.controldata()
|
||||||
|
self.assertEquals(data, dict())
|
||||||
|
|
||||||
|
subprocess.check_output = check_output_generic_exception
|
||||||
|
self.assertRaises(Exception, self.p.controldata())
|
||||||
|
|
||||||
|
def test_read_postmaster_opts(self):
|
||||||
|
m = mock_open(read_data=postmaster_opts_string())
|
||||||
|
with patch.object(builtins, 'open', m):
|
||||||
|
data = self.p.read_postmaster_opts()
|
||||||
|
self.assertEquals(data['wal_level'], 'hot_standby')
|
||||||
|
self.assertEquals(int(data['max_replication_slots']), 5)
|
||||||
|
self.assertEqual(data.get('D'), None)
|
||||||
|
|
||||||
|
m.side_effect = IOError("foo")
|
||||||
|
data = self.p.read_postmaster_opts()
|
||||||
|
self.assertEqual(data, dict())
|
||||||
|
|
||||||
|
m.side_effect = Exception("foo")
|
||||||
|
self.assertRaises(Exception, self.p.read_postmaster_opts())
|
||||||
|
|
||||||
|
@patch('subprocess.Popen')
|
||||||
|
@patch.object(builtins, 'open', MagicMock(return_value=42))
|
||||||
|
def test_single_user_mode(self, subprocess_popen_mock):
|
||||||
|
subprocess_popen_mock.return_value.wait.return_value = 0
|
||||||
|
self.assertEquals(self.p.single_user_mode(options=dict(archive_mode='on', archive_command='false')), 0)
|
||||||
|
subprocess_popen_mock.assert_called_once_with(['postgres', '--single', '-D', self.p.data_dir,
|
||||||
|
'-c', 'archive_command=false', '-c', 'archive_mode=on',
|
||||||
|
'postgres'], stdin=subprocess.PIPE,
|
||||||
|
stdout=42,
|
||||||
|
stderr=subprocess.STDOUT)
|
||||||
|
subprocess_popen_mock.reset_mock()
|
||||||
|
self.assertEquals(self.p.single_user_mode(command="CHECKPOINT"), 0)
|
||||||
|
subprocess_popen_mock.assert_called_once_with(['postgres', '--single', '-D', self.p.data_dir,
|
||||||
|
'postgres'], stdin=subprocess.PIPE,
|
||||||
|
stdout=42,
|
||||||
|
stderr=subprocess.STDOUT)
|
||||||
|
subprocess_popen_mock.return_value = None
|
||||||
|
self.assertEquals(self.p.single_user_mode(), 1)
|
||||||
|
|
||||||
|
def fake_listdir(path):
|
||||||
|
if path.endswith(os.path.join('pg_xlog', 'archive_status')):
|
||||||
|
return ["a", "b", "c"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
@patch('os.listdir', MagicMock(side_effect=fake_listdir))
|
||||||
|
@patch('os.path.isdir', MagicMock(return_value=True))
|
||||||
|
@patch('os.unlink', return_value=True)
|
||||||
|
@patch('os.remove', return_value=True)
|
||||||
|
@patch('os.path.islink', return_value=False)
|
||||||
|
@patch('os.path.isfile', return_value=True)
|
||||||
|
def test_cleanup_archive_status(self, mock_file, mock_link, mock_remove, mock_unlink):
|
||||||
|
ap = os.path.join(self.p.data_dir, 'pg_xlog', 'archive_status/')
|
||||||
|
self.p.cleanup_archive_status()
|
||||||
|
mock_remove.assert_has_calls([mock.call(ap+'a'), mock.call(ap+'b'), mock.call(ap+'c')])
|
||||||
|
mock_unlink.assert_not_called()
|
||||||
|
|
||||||
|
mock_remove.reset_mock()
|
||||||
|
|
||||||
|
mock_file.return_value = False
|
||||||
|
mock_link.return_value = True
|
||||||
|
self.p.cleanup_archive_status()
|
||||||
|
mock_unlink.assert_has_calls([mock.call(ap+'a'), mock.call(ap+'b'), mock.call(ap+'c')])
|
||||||
|
mock_remove.assert_not_called()
|
||||||
|
|
||||||
|
mock_unlink.reset_mock()
|
||||||
|
mock_remove.reset_mock()
|
||||||
|
|
||||||
|
mock_file.side_effect = Exception("foo")
|
||||||
|
mock_link.side_effect = Exception("foo")
|
||||||
|
self.p.cleanup_archive_status()
|
||||||
|
mock_unlink.assert_not_called()
|
||||||
|
mock_remove.assert_not_called()
|
||||||
|
|
||||||
|
@patch('subprocess.check_output', MagicMock(return_value=0, side_effect=pg_controldata_string))
|
||||||
|
def test_sysid(self):
|
||||||
|
self.assertEqual(self.p.sysid, "6200971513092291716")
|
||||||
|
|
||||||
|
@patch('os.path.isfile', MagicMock(return_value=True))
|
||||||
|
@patch('shutil.copy', side_effect=Exception)
|
||||||
|
def test_save_configuration_files(self, mock_copy):
|
||||||
|
shutil.copy = mock_copy
|
||||||
|
self.p.save_configuration_files()
|
||||||
|
|
||||||
|
@patch('os.path.isfile', MagicMock(side_effect=is_file_raise_on_backup))
|
||||||
|
@patch('shutil.copy', side_effect=Exception)
|
||||||
|
def test_restore_configuration_files(self, mock_copy):
|
||||||
|
shutil.copy = mock_copy
|
||||||
|
self.p.restore_configuration_files()
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import unittest
|
|
||||||
from mock import MagicMock, patch
|
|
||||||
import os
|
|
||||||
from patroni.scripts.restore import Restore, WALERestore
|
|
||||||
|
|
||||||
|
|
||||||
def fake_cursor_fetchone(*args, **kwargs):
|
|
||||||
return ('16777216',)
|
|
||||||
|
|
||||||
|
|
||||||
def fake_call_fail_for_wal_e(*args, **kwargs):
|
|
||||||
if len(args) > 0 and 'backup-fetch' in args[0]:
|
|
||||||
return 1
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def fake_call_fail_for_base_backup(*args, **kwargs):
|
|
||||||
if len(args) > 0 and 'backup-fetch' in args[0]:
|
|
||||||
return 0
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
def fake_backup_data(self, *args, **kwargs):
|
|
||||||
""" return the fake result of WAL-E backup-list"""
|
|
||||||
return """name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start wal_segment_backup_stop wal_segment_offset_backup_stop
|
|
||||||
base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z 167772160 00000001000000000000007F 00000040 00000001000000000000007F 00000240
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class TestRestore(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.restore = Restore("batman", "master", "/data", "host=batman port=5432 user=batman")
|
|
||||||
pass
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_parse_connstring(self):
|
|
||||||
self.assertDictEqual(self.restore.master_connection, {'host': 'batman', 'port': '5432', 'user': 'batman'})
|
|
||||||
|
|
||||||
@patch('subprocess.call', MagicMock(return_value=0))
|
|
||||||
def test_run(self):
|
|
||||||
ret = self.restore.run()
|
|
||||||
self.assertEqual(ret, 0)
|
|
||||||
|
|
||||||
@patch('subprocess.call', MagicMock(return_value=1))
|
|
||||||
def test_run_fail(self):
|
|
||||||
ret = self.restore.run()
|
|
||||||
self.assertEqual(ret, 1)
|
|
||||||
|
|
||||||
|
|
||||||
@patch('os.access', MagicMock(return_value=True))
|
|
||||||
@patch('os.makedirs', MagicMock(return_value=True))
|
|
||||||
@patch('os.path.exists', MagicMock(return_value=True))
|
|
||||||
@patch('os.path.isdir', MagicMock(return_value=True))
|
|
||||||
@patch('psycopg2.extensions.cursor.fetchone', MagicMock(side_effect=fake_cursor_fetchone))
|
|
||||||
@patch('psycopg2.extensions.cursor', MagicMock(autospec=True))
|
|
||||||
@patch('psycopg2.extensions.connection', MagicMock(autospec=True))
|
|
||||||
@patch('psycopg2.connect', MagicMock(autospec=True))
|
|
||||||
@patch('subprocess.check_output', MagicMock(side_effect=fake_backup_data))
|
|
||||||
class TestWALERestore(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
env = {}
|
|
||||||
env['WAL_S3_BUCKET'] = 'batman'
|
|
||||||
env['WALE_BACKUP_THRESHOLD_PERCENTAGE'] = 100
|
|
||||||
env['WALE_BACKUP_THRESHOLD_MEGABYTES'] = 100
|
|
||||||
self.wale_restore = WALERestore("batman", "master", "/data", "host=batman port=5432 user=batman", env=env)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_setup(self):
|
|
||||||
self.wale_restore.setup()
|
|
||||||
self.assertFalse(self.wale_restore.init_error)
|
|
||||||
|
|
||||||
# have to redefine the class-level os.access mock inside the function
|
|
||||||
# since the class-level mock will be applied after the function level one.
|
|
||||||
@patch('os.access', return_value=False)
|
|
||||||
def test_setup_fail(self, mock_no_access):
|
|
||||||
os.access = mock_no_access
|
|
||||||
self.wale_restore.setup()
|
|
||||||
self.assertTrue(self.wale_restore.init_error)
|
|
||||||
|
|
||||||
# The 3 tests above only differ with the mock function instead of a subprocess call
|
|
||||||
# in the first one, subprocess call should return success only for wal-e command,
|
|
||||||
# checking the primary use-case of restoring from WAL-E backup.
|
|
||||||
# In the second one, we test fallbacks by failing at WAL-E, but succeeding at
|
|
||||||
# pg_basebackup.
|
|
||||||
# Finally, the last use case is when all subprocess.call fails. resulting in a
|
|
||||||
# failure to restore from replica
|
|
||||||
@patch('subprocess.call',
|
|
||||||
MagicMock(side_effect=lambda *args, **kwargs: 0 if 'wal-e' in args[0] else 1))
|
|
||||||
def test_run(self):
|
|
||||||
self.wale_restore.setup()
|
|
||||||
ret = self.wale_restore.run()
|
|
||||||
self.assertEqual(ret, 0)
|
|
||||||
|
|
||||||
@patch('subprocess.call',
|
|
||||||
MagicMock(side_effect=lambda *args, **kwargs: 0 if 'pg_basebackup' in args[0] else 1))
|
|
||||||
def test_run_fallback(self):
|
|
||||||
self.wale_restore.setup()
|
|
||||||
ret = self.wale_restore.run()
|
|
||||||
self.assertEqual(ret, 0)
|
|
||||||
|
|
||||||
@patch('subprocess.call', MagicMock(return_value=1))
|
|
||||||
def test_run_all_fail(self):
|
|
||||||
self.wale_restore.setup()
|
|
||||||
ret = self.wale_restore.run()
|
|
||||||
self.assertEqual(ret, 1)
|
|
||||||
+14
-40
@@ -1,56 +1,34 @@
|
|||||||
import os
|
|
||||||
import time
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from patroni.exceptions import DCSError
|
from mock import Mock, patch
|
||||||
|
from patroni.exceptions import PatroniException
|
||||||
from patroni.utils import Retry, RetryFailedError, reap_children, sigchld_handler, sigterm_handler, sleep
|
from patroni.utils import Retry, RetryFailedError, reap_children, sigchld_handler, sigterm_handler, sleep
|
||||||
|
|
||||||
|
|
||||||
def nop(*args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def os_waitpid(a, b):
|
|
||||||
return (0, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def time_sleep(_):
|
def time_sleep(_):
|
||||||
sigchld_handler(None, None)
|
sigchld_handler(None, None)
|
||||||
|
|
||||||
|
|
||||||
class TestUtils(unittest.TestCase):
|
class TestUtils(unittest.TestCase):
|
||||||
|
|
||||||
def __init__(self, method_name='runTest'):
|
|
||||||
self.setUp = self.set_up
|
|
||||||
self.tearDown = self.tear_down
|
|
||||||
super(TestUtils, self).__init__(method_name)
|
|
||||||
|
|
||||||
def set_up(self):
|
|
||||||
self.time_sleep = time.sleep
|
|
||||||
time.sleep = nop
|
|
||||||
|
|
||||||
def tear_down(self):
|
|
||||||
time.sleep = self.time_sleep
|
|
||||||
|
|
||||||
def test_sigterm_handler(self):
|
def test_sigterm_handler(self):
|
||||||
self.assertRaises(SystemExit, sigterm_handler, None, None)
|
self.assertRaises(SystemExit, sigterm_handler, None, None)
|
||||||
|
|
||||||
|
@patch('time.sleep', Mock())
|
||||||
def test_reap_children(self):
|
def test_reap_children(self):
|
||||||
reap_children()
|
reap_children()
|
||||||
os.waitpid = os_waitpid
|
with patch('os.waitpid', Mock(return_value=(0, 0))):
|
||||||
sigchld_handler(None, None)
|
sigchld_handler(None, None)
|
||||||
reap_children()
|
reap_children()
|
||||||
|
|
||||||
|
@patch('time.sleep', time_sleep)
|
||||||
def test_sleep(self):
|
def test_sleep(self):
|
||||||
time.sleep = time_sleep
|
|
||||||
sleep(0.01)
|
sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
@patch('time.sleep', Mock())
|
||||||
class TestRetrySleeper(unittest.TestCase):
|
class TestRetrySleeper(unittest.TestCase):
|
||||||
|
|
||||||
def _pass(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _fail(self, times=1):
|
def _fail(self, times=1):
|
||||||
scope = dict(times=0)
|
scope = dict(times=0)
|
||||||
|
|
||||||
@@ -59,7 +37,7 @@ class TestRetrySleeper(unittest.TestCase):
|
|||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
scope['times'] += 1
|
scope['times'] += 1
|
||||||
raise DCSError('Failed!')
|
raise PatroniException('Failed!')
|
||||||
return inner
|
return inner
|
||||||
|
|
||||||
def _makeOne(self, *args, **kwargs):
|
def _makeOne(self, *args, **kwargs):
|
||||||
@@ -78,24 +56,20 @@ class TestRetrySleeper(unittest.TestCase):
|
|||||||
self.assertEquals(retry._attempts, 1)
|
self.assertEquals(retry._attempts, 1)
|
||||||
|
|
||||||
def test_maximum_delay(self):
|
def test_maximum_delay(self):
|
||||||
def sleep_func(_time):
|
retry = self._makeOne(delay=10, max_tries=100)
|
||||||
pass
|
|
||||||
|
|
||||||
retry = self._makeOne(delay=10, max_tries=100, sleep_func=sleep_func)
|
|
||||||
retry(self._fail(times=10))
|
retry(self._fail(times=10))
|
||||||
self.assertTrue(retry._cur_delay < 4000, retry._cur_delay)
|
self.assertTrue(retry._cur_delay < 4000, retry._cur_delay)
|
||||||
# gevent's sleep function is picky about the type
|
# gevent's sleep function is picky about the type
|
||||||
self.assertEquals(type(retry._cur_delay), float)
|
self.assertEquals(type(retry._cur_delay), float)
|
||||||
|
|
||||||
def test_deadline(self):
|
def test_deadline(self):
|
||||||
def sleep_func(_time):
|
retry = self._makeOne(deadline=0.0001)
|
||||||
pass
|
|
||||||
|
|
||||||
retry = self._makeOne(deadline=0.0001, sleep_func=sleep_func)
|
|
||||||
self.assertRaises(RetryFailedError, retry, self._fail(times=100))
|
self.assertRaises(RetryFailedError, retry, self._fail(times=100))
|
||||||
|
|
||||||
def test_copy(self):
|
def test_copy(self):
|
||||||
_sleep = lambda t: None
|
def _sleep(t):
|
||||||
|
None
|
||||||
|
|
||||||
retry = self._makeOne(sleep_func=_sleep)
|
retry = self._makeOne(sleep_func=_sleep)
|
||||||
rcopy = retry.copy()
|
rcopy = retry.copy()
|
||||||
self.assertTrue(rcopy.sleep_func is _sleep)
|
self.assertTrue(rcopy.sleep_func is _sleep)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import unittest
|
||||||
|
from mock import MagicMock, patch, PropertyMock
|
||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
import subprocess
|
||||||
|
from patroni.scripts.wale_restore import WALERestore
|
||||||
|
|
||||||
|
|
||||||
|
def fake_cursor_fetchone(*args, **kwargs):
|
||||||
|
return ('16777216',)
|
||||||
|
|
||||||
|
|
||||||
|
def fake_call_fail_for_wal_e(*args, **kwargs):
|
||||||
|
if len(args) > 0 and 'backup-fetch' in args[0]:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def fake_call_fail_for_base_backup(*args, **kwargs):
|
||||||
|
if len(args) > 0 and 'backup-fetch' in args[0]:
|
||||||
|
return 0
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def fake_backup_data(self, *args, **kwargs):
|
||||||
|
""" return the fake result of WAL-E backup-list"""
|
||||||
|
return """name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start wal_segment_backup_stop wal_segment_offset_backup_stop
|
||||||
|
base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z 167772160 00000001000000000000007F 00000040 00000001000000000000007F 00000240
|
||||||
|
"""
|
||||||
|
|
||||||
|
def fake_backup_data_2(self, *args, **kwargs):
|
||||||
|
""" return the fake result of WAL-E backup-list"""
|
||||||
|
return """name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start wal_segment_backup_stop wal_segment_offset_backup_stop """
|
||||||
|
|
||||||
|
def fake_backup_data_3(self, *args, **kwargs):
|
||||||
|
""" return the fake result of WAL-E backup-list"""
|
||||||
|
return """name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start wal_segment_backup_stop
|
||||||
|
base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z 167772160 00000001000000000000007F 00000040 00000001000000000000007F 00000240
|
||||||
|
"""
|
||||||
|
|
||||||
|
def fake_backup_data_4(self, *args, **kwargs):
|
||||||
|
""" return the fake result of WAL-E backup-list"""
|
||||||
|
return """name last_modified expanded_size_foo wal_segment_backup_start wal_segment_offset_backup_start wal_segment_backup_stop wal_segment_offset_backup_stop
|
||||||
|
base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z 167772160 00000001000000000000007F 00000040 00000001000000000000007F 00000240
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@patch('os.access', MagicMock(return_value=True))
|
||||||
|
@patch('os.makedirs', MagicMock(return_value=True))
|
||||||
|
@patch('os.path.exists', MagicMock(return_value=True))
|
||||||
|
@patch('os.path.isdir', MagicMock(return_value=True))
|
||||||
|
@patch('psycopg2.extensions.cursor.fetchone', MagicMock(side_effect=fake_cursor_fetchone))
|
||||||
|
@patch('psycopg2.extensions.cursor', MagicMock(autospec=True))
|
||||||
|
@patch('psycopg2.extensions.connection', MagicMock(autospec=True))
|
||||||
|
@patch('psycopg2.connect', MagicMock(autospec=True))
|
||||||
|
@patch('subprocess.check_output', MagicMock(side_effect=fake_backup_data))
|
||||||
|
class TestWALERestore(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.wale_restore = WALERestore("batman", "/data",
|
||||||
|
"host=batman port=5432 user=batman", "/etc", 100, 100, 1)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_should_use_s3_to_create_replica(self):
|
||||||
|
with patch('psycopg2.connect', MagicMock(side_effect=psycopg2.Error("foo"))):
|
||||||
|
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
|
||||||
|
with patch('subprocess.check_output', MagicMock(side_effect=subprocess.CalledProcessError(1, "cmd", "foo"))):
|
||||||
|
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
|
||||||
|
with patch('subprocess.check_output', MagicMock(side_effect=fake_backup_data_2)):
|
||||||
|
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
|
||||||
|
with patch('subprocess.check_output', MagicMock(side_effect=fake_backup_data_3)):
|
||||||
|
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
|
||||||
|
with patch('subprocess.check_output', MagicMock(side_effect=fake_backup_data_4)):
|
||||||
|
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
|
||||||
|
|
||||||
|
self.wale_restore.should_use_s3_to_create_replica()
|
||||||
|
|
||||||
|
def test_create_replica_with_s3(self):
|
||||||
|
with patch('subprocess.call', MagicMock(return_value=0)):
|
||||||
|
self.assertEqual(self.wale_restore.create_replica_with_s3(), 0)
|
||||||
|
with patch('subprocess.call', MagicMock(side_effect=Exception("foo"))):
|
||||||
|
self.assertEqual(self.wale_restore.create_replica_with_s3(), 1)
|
||||||
|
|
||||||
|
def test_run(self):
|
||||||
|
with patch.object(self.wale_restore, 'init_error', PropertyMock(return_value=True)):
|
||||||
|
self.assertEqual(self.wale_restore.run(), 2)
|
||||||
|
with patch.object(self.wale_restore, 'should_use_s3_to_create_replica', MagicMock(return_value=True)):
|
||||||
|
with patch.object(self.wale_restore, 'create_replica_with_s3', MagicMock(return_value=0)):
|
||||||
|
self.assertEqual(self.wale_restore.run(), 0)
|
||||||
+86
-78
@@ -1,130 +1,112 @@
|
|||||||
import patroni.zookeeper
|
import six
|
||||||
import requests
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from mock import Mock, patch
|
||||||
from patroni.dcs import Leader
|
from patroni.dcs import Leader
|
||||||
from patroni.zookeeper import ExhibitorEnsembleProvider, ZooKeeper, ZooKeeperError
|
from patroni.zookeeper import ExhibitorEnsembleProvider, ZooKeeper, ZooKeeperError
|
||||||
from kazoo.client import KazooState
|
from kazoo.client import KazooState
|
||||||
from kazoo.exceptions import NoNodeError, NodeExistsError
|
from kazoo.exceptions import NoNodeError, NodeExistsError
|
||||||
from kazoo.protocol.states import ZnodeStat
|
from kazoo.protocol.states import ZnodeStat
|
||||||
from test_etcd import MockPostgresql, requests_get
|
from test_etcd import SleepException, requests_get
|
||||||
|
|
||||||
|
|
||||||
class MockEvent:
|
class MockKazooClient(Mock):
|
||||||
|
|
||||||
def clear(self):
|
leader = False
|
||||||
pass
|
exists = True
|
||||||
|
handler = Mock()
|
||||||
def set(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def wait(self, timeout):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def isSet(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class MockEventHandler:
|
|
||||||
|
|
||||||
def event_object(self):
|
|
||||||
return MockEvent()
|
|
||||||
|
|
||||||
|
|
||||||
class SleepException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class MockKazooClient:
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
self.handler = MockEventHandler()
|
|
||||||
self.leader = False
|
|
||||||
self.exists = True
|
|
||||||
|
|
||||||
def start(self, timeout):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def client_id(self):
|
def client_id(self):
|
||||||
return (-1, '')
|
return (-1, '')
|
||||||
|
|
||||||
def add_listener(self, cb):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def retry(self, func, *args, **kwargs):
|
def retry(self, func, *args, **kwargs):
|
||||||
func(*args, **kwargs)
|
func(*args, **kwargs)
|
||||||
|
|
||||||
def get(self, path, watch=None):
|
def get(self, path, watch=None):
|
||||||
if path == '/service/test/no_node':
|
if not isinstance(path, six.string_types):
|
||||||
|
raise TypeError("Invalid type for 'path' (string expected)")
|
||||||
|
if path == '/no_node':
|
||||||
raise NoNodeError
|
raise NoNodeError
|
||||||
elif path == '/service/test/other_exception':
|
|
||||||
raise Exception()
|
|
||||||
elif '/members/' in path:
|
elif '/members/' in path:
|
||||||
return (
|
return (
|
||||||
'postgres://repuser:rep-pass@localhost:5434/postgres?application_name=http://127.0.0.1:8009/patroni',
|
b'postgres://repuser:rep-pass@localhost:5434/postgres?application_name=http://127.0.0.1:8009/patroni',
|
||||||
ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0 if self.exists else -1, 0, 0, 0)
|
||||||
)
|
)
|
||||||
elif path.endswith('/optime/leader'):
|
elif path.endswith('/optime/leader'):
|
||||||
return '1'
|
return (b'1', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||||||
elif path.endswith('/leader'):
|
elif path.endswith('/leader'):
|
||||||
if self.leader:
|
if self.leader:
|
||||||
return ('foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0))
|
return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0))
|
||||||
return ('foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||||||
|
elif path.endswith('/initialize'):
|
||||||
|
return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||||||
|
return (b'', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||||||
|
|
||||||
def get_children(self, path, watch=None, include_data=False):
|
def get_children(self, path, watch=None, include_data=False):
|
||||||
|
if not isinstance(path, six.string_types):
|
||||||
|
raise TypeError("Invalid type for 'path' (string expected)")
|
||||||
|
if path.startswith('/no_node'):
|
||||||
|
raise NoNodeError
|
||||||
|
elif path in ['/service/bla/', '/service/test/']:
|
||||||
|
return ['initialize', 'leader', 'members', 'optime', 'failover']
|
||||||
return ['foo', 'bar', 'buzz']
|
return ['foo', 'bar', 'buzz']
|
||||||
|
|
||||||
def create(self, path, value="", acl=None, ephemeral=False, sequence=False, makepath=False):
|
def create(self, path, value=b"", acl=None, ephemeral=False, sequence=False, makepath=False):
|
||||||
|
if not isinstance(path, six.string_types):
|
||||||
|
raise TypeError("Invalid type for 'path' (string expected)")
|
||||||
|
if not isinstance(value, (six.binary_type,)):
|
||||||
|
raise TypeError("Invalid type for 'value' (must be a byte string)")
|
||||||
if path.endswith('/initialize') or path == '/service/test/optime/leader':
|
if path.endswith('/initialize') or path == '/service/test/optime/leader':
|
||||||
raise Exception
|
raise Exception
|
||||||
elif value == 'retry' or (value == 'exists' and self.exists):
|
elif value == b'retry' or (value == b'exists' and self.exists):
|
||||||
raise NodeExistsError
|
raise NodeExistsError
|
||||||
|
|
||||||
def set(self, path, value, version=-1):
|
def set(self, path, value, version=-1):
|
||||||
|
if not isinstance(path, six.string_types):
|
||||||
|
raise TypeError("Invalid type for 'path' (string expected)")
|
||||||
|
if not isinstance(value, (six.binary_type,)):
|
||||||
|
raise TypeError("Invalid type for 'value' (must be a byte string)")
|
||||||
if path == '/service/bla/optime/leader':
|
if path == '/service/bla/optime/leader':
|
||||||
raise Exception
|
raise Exception
|
||||||
|
if path == '/service/test/members/bar':
|
||||||
|
if value == b'retry':
|
||||||
|
return
|
||||||
|
if path == '/service/test/failover':
|
||||||
|
if value == b'Exception':
|
||||||
|
raise Exception
|
||||||
|
elif value == b'ok':
|
||||||
|
return
|
||||||
raise NoNodeError
|
raise NoNodeError
|
||||||
|
|
||||||
def delete(self, path, version=-1, recursive=False):
|
def delete(self, path, version=-1, recursive=False):
|
||||||
|
if not isinstance(path, six.string_types):
|
||||||
|
raise TypeError("Invalid type for 'path' (string expected)")
|
||||||
self.exists = False
|
self.exists = False
|
||||||
if path == '/service/test/leader':
|
if path == '/service/test/leader':
|
||||||
if self.leader:
|
if self.leader:
|
||||||
return
|
return
|
||||||
self.leader = True
|
self.leader = True
|
||||||
raise Exception
|
raise Exception
|
||||||
|
elif path == '/service/test/members/buzz':
|
||||||
def set_hosts(self, hosts, randomize_hosts=None):
|
raise Exception
|
||||||
pass
|
elif path.endswith('/initialize') or path == '/service/test/members/bar':
|
||||||
|
raise NoNodeError
|
||||||
|
|
||||||
def exhibitor_sleep(_):
|
|
||||||
raise SleepException
|
|
||||||
|
|
||||||
|
|
||||||
|
@patch('requests.get', requests_get)
|
||||||
|
@patch('patroni.zookeeper.sleep', Mock(side_effect=SleepException()))
|
||||||
class TestExhibitorEnsembleProvider(unittest.TestCase):
|
class TestExhibitorEnsembleProvider(unittest.TestCase):
|
||||||
|
|
||||||
def __init__(self, method_name='runTest'):
|
|
||||||
self.setUp = self.set_up
|
|
||||||
super(TestExhibitorEnsembleProvider, self).__init__(method_name)
|
|
||||||
|
|
||||||
def set_up(self):
|
|
||||||
requests.get = requests_get
|
|
||||||
patroni.zookeeper.sleep = exhibitor_sleep
|
|
||||||
|
|
||||||
def test_init(self):
|
def test_init(self):
|
||||||
self.assertRaises(SleepException, ExhibitorEnsembleProvider, ['localhost'], 8181)
|
self.assertRaises(SleepException, ExhibitorEnsembleProvider, ['localhost'], 8181)
|
||||||
|
|
||||||
|
|
||||||
class TestZooKeeper(unittest.TestCase):
|
class TestZooKeeper(unittest.TestCase):
|
||||||
|
|
||||||
def __init__(self, method_name='runTest'):
|
@patch('requests.get', requests_get)
|
||||||
self.setUp = self.set_up
|
@patch('patroni.zookeeper.KazooClient', MockKazooClient)
|
||||||
super(TestZooKeeper, self).__init__(method_name)
|
def setUp(self):
|
||||||
|
|
||||||
def set_up(self):
|
|
||||||
requests.get = requests_get
|
|
||||||
patroni.zookeeper.KazooClient = MockKazooClient
|
|
||||||
self.zk = ZooKeeper('foo', {'exhibitor': {'hosts': ['localhost', 'exhibitor'], 'port': 8181}, 'scope': 'test'})
|
self.zk = ZooKeeper('foo', {'exhibitor': {'hosts': ['localhost', 'exhibitor'], 'port': 8181}, 'scope': 'test'})
|
||||||
|
|
||||||
def test_session_listener(self):
|
def test_session_listener(self):
|
||||||
@@ -132,11 +114,15 @@ class TestZooKeeper(unittest.TestCase):
|
|||||||
|
|
||||||
def test_get_node(self):
|
def test_get_node(self):
|
||||||
self.assertIsNone(self.zk.get_node('/no_node'))
|
self.assertIsNone(self.zk.get_node('/no_node'))
|
||||||
self.assertIsNone(self.zk.get_node('/other_exception'))
|
|
||||||
|
def test_get_children(self):
|
||||||
|
self.assertListEqual(self.zk.get_children('/no_node'), [])
|
||||||
|
|
||||||
def test__inner_load_cluster(self):
|
def test__inner_load_cluster(self):
|
||||||
self.zk._base_path = self.zk._base_path.replace('test', 'bla')
|
self.zk._base_path = self.zk._base_path.replace('test', 'bla')
|
||||||
self.zk._inner_load_cluster()
|
self.zk._inner_load_cluster()
|
||||||
|
self.zk._base_path = self.zk._base_path = '/no_node'
|
||||||
|
self.zk._inner_load_cluster()
|
||||||
|
|
||||||
def test_get_cluster(self):
|
def test_get_cluster(self):
|
||||||
self.assertRaises(ZooKeeperError, self.zk.get_cluster)
|
self.assertRaises(ZooKeeperError, self.zk.get_cluster)
|
||||||
@@ -146,23 +132,45 @@ class TestZooKeeper(unittest.TestCase):
|
|||||||
self.zk.touch_member('foo')
|
self.zk.touch_member('foo')
|
||||||
self.zk.delete_leader()
|
self.zk.delete_leader()
|
||||||
|
|
||||||
def test_race(self):
|
def test_set_failover_value(self):
|
||||||
self.assertFalse(self.zk.race('/initialize'))
|
self.zk.set_failover_value('')
|
||||||
|
self.zk.set_failover_value('ok')
|
||||||
|
self.zk.set_failover_value('Exception')
|
||||||
|
|
||||||
|
def test_initialize(self):
|
||||||
|
self.assertFalse(self.zk.initialize())
|
||||||
|
|
||||||
|
def test_cancel_initialization(self):
|
||||||
|
self.zk.cancel_initialization()
|
||||||
|
|
||||||
def test_touch_member(self):
|
def test_touch_member(self):
|
||||||
|
self.zk._name = 'buzz'
|
||||||
|
self.zk.get_cluster()
|
||||||
self.zk.touch_member('new')
|
self.zk.touch_member('new')
|
||||||
|
self.zk._name = 'bar'
|
||||||
|
self.zk.touch_member('new')
|
||||||
|
self.zk._name = 'na'
|
||||||
|
self.zk.client.exists = 1
|
||||||
self.zk.touch_member('exists')
|
self.zk.touch_member('exists')
|
||||||
|
self.zk._name = 'bar'
|
||||||
|
self.zk.touch_member('retry')
|
||||||
|
self.zk.fetch_cluster = True
|
||||||
|
self.zk.get_cluster()
|
||||||
self.zk.touch_member('retry')
|
self.zk.touch_member('retry')
|
||||||
|
|
||||||
def test_take_leader(self):
|
def test_take_leader(self):
|
||||||
self.zk.take_leader()
|
self.zk.take_leader()
|
||||||
|
|
||||||
def test_update_leader(self):
|
def test_update_leader(self):
|
||||||
self.zk.last_leader_operation = -1
|
self.assertTrue(self.zk.update_leader())
|
||||||
self.assertTrue(self.zk.update_leader(MockPostgresql()))
|
|
||||||
|
def test_write_leader_optime(self):
|
||||||
|
self.zk.last_leader_operation = '0'
|
||||||
|
self.zk.write_leader_optime('1')
|
||||||
self.zk._base_path = self.zk._base_path.replace('test', 'bla')
|
self.zk._base_path = self.zk._base_path.replace('test', 'bla')
|
||||||
self.zk.last_leader_operation = -1
|
self.zk.write_leader_optime('2')
|
||||||
self.assertTrue(self.zk.update_leader(MockPostgresql()))
|
|
||||||
|
|
||||||
def test_watch(self):
|
def test_watch(self):
|
||||||
self.zk.watch(0)
|
self.zk.watch(0)
|
||||||
|
self.zk.event.isSet = lambda: True
|
||||||
|
self.zk.watch(0)
|
||||||
|
|||||||
Reference in New Issue
Block a user