mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-26 15:40:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aa173e79a | ||
|
|
c6252bc004 | ||
|
|
2b5c08d17d | ||
|
|
68606c645d | ||
|
|
4a91558bf6 | ||
|
|
39f5f7982c | ||
|
|
56c736a912 | ||
|
|
1f829a4b34 | ||
|
|
711d53980f | ||
|
|
bbc67b2460 | ||
|
|
2559ba8ca2 | ||
|
|
4c34136d44 | ||
|
|
ee0ba74a84 | ||
|
|
874b5fbe8d | ||
|
|
576351a807 | ||
|
|
fb89e75ce4 | ||
|
|
c293d3d7ec | ||
|
|
8fda957804 | ||
|
|
574e1dba04 | ||
|
|
27dd9b4c6e | ||
|
|
663bbb1048 | ||
|
|
a5e79bce9d | ||
|
|
8c0712047e | ||
|
|
82176765e9 | ||
|
|
cffc7d8dc5 | ||
|
|
1e984c3f00 | ||
|
|
47cc572a3d | ||
|
|
d138a8db17 | ||
|
|
1290b30b84 | ||
|
|
ec78777778 | ||
|
|
b38d98a6a3 | ||
|
|
b299b12f58 | ||
|
|
c6417b2558 | ||
|
|
28b00dea16 | ||
|
|
038b5aed72 | ||
|
|
66543f41a3 | ||
|
|
37b020e7a3 | ||
|
|
e9a834739b | ||
|
|
7e53a604d4 | ||
|
|
8cc3d91021 | ||
|
|
46cb62b0cc | ||
|
|
1e573aec8f | ||
|
|
6c9a870f09 | ||
|
|
48a6af6994 | ||
|
|
4594bc98da | ||
|
|
bb07076343 | ||
|
|
67c4b6b105 | ||
|
|
e38dfaf1ba | ||
|
|
298357c099 | ||
|
|
e7329fb903 | ||
|
|
5265e71fc2 | ||
|
|
10c7fa41f3 | ||
|
|
7ca55359de | ||
|
|
1100136d8d | ||
|
|
453e68637a | ||
|
|
0b1bfeca5b | ||
|
|
5c8399e4fa | ||
|
|
540ee2b3c7 | ||
|
|
dc259298dc | ||
|
|
41cdbc0304 | ||
|
|
0d70199941 | ||
|
|
05951f9b5b | ||
|
|
41d1724705 | ||
|
|
0742ffe02e | ||
|
|
4ca514ceb3 | ||
|
|
c2b91d0195 | ||
|
|
53bcc5c9bb | ||
|
|
6bdaa7fb88 | ||
|
|
2086c90a4a | ||
|
|
5c7efa3a65 | ||
|
|
dff79395a9 | ||
|
|
39d16fe2f9 | ||
|
|
57a0ac9086 | ||
|
|
5ba1294d60 | ||
|
|
3f7fa4b41f | ||
|
|
d98f255b64 | ||
|
|
80abe67ed2 | ||
|
|
ebf64828e1 | ||
|
|
07e95912a2 | ||
|
|
19c80df442 | ||
|
|
b0beecffea | ||
|
|
5de0790e89 | ||
|
|
3cd5c8d3dd | ||
|
|
75cd8422eb | ||
|
|
fef4e046e1 | ||
|
|
005d181507 | ||
|
|
f58ff3a96f | ||
|
|
a642860ae8 | ||
|
|
f082ecf60b | ||
|
|
db9b62b7ed | ||
|
|
33ff372ef6 | ||
|
|
1fb562e118 | ||
|
|
5c2cad20d7 | ||
|
|
0e8220f9f2 | ||
|
|
46f1c5b690 | ||
|
|
adbea3929c | ||
|
|
4d72eef164 | ||
|
|
c0fae1b2e9 | ||
|
|
1dcdd6eaa0 | ||
|
|
8028877be0 | ||
|
|
11359a26a9 | ||
|
|
030ba4c898 | ||
|
|
0afdb816ba | ||
|
|
1374fb3a2d | ||
|
|
fa6bd51ad1 | ||
|
|
e428c8d0fa | ||
|
|
64e3e2fb2d | ||
|
|
de6cba6e04 | ||
|
|
418f8a082f | ||
|
|
366ed9cc52 | ||
|
|
6dc1d9c88e | ||
|
|
2743dc0f7a | ||
|
|
b498b22584 | ||
|
|
ebbc8bad4c | ||
|
|
8c88e5c7b2 | ||
|
|
a9a70d44e2 | ||
|
|
799d4c9bb8 | ||
|
|
62f14dfd10 | ||
|
|
22e4af3fb1 | ||
|
|
e643321ab7 | ||
|
|
9fdd021e08 | ||
|
|
3d1fe3fa49 | ||
|
|
89ef5da5ae | ||
|
|
1635f5269e | ||
|
|
ac49835a3c | ||
|
|
93b9046aed | ||
|
|
3977626fc2 | ||
|
|
5c63c9ffbd | ||
|
|
05bcbe90eb | ||
|
|
74166e996c | ||
|
|
9bb07fb419 | ||
|
|
4e61ef06a8 | ||
|
|
a388fdb99d | ||
|
|
b472a32589 | ||
|
|
d4e98b1d5c | ||
|
|
a09f905a78 | ||
|
|
dd5bc1bc9b | ||
|
|
a573983753 | ||
|
|
52806bb04d | ||
|
|
688736c6b1 | ||
|
|
96da6340a9 | ||
|
|
1fc8b43b36 | ||
|
|
97f7576fab | ||
|
|
b58ddc559e | ||
|
|
ef0b3c2296 | ||
|
|
c50f072b31 | ||
|
|
b5d6b7d13d | ||
|
|
296f4a7ff8 | ||
|
|
494887f47e | ||
|
|
fa7aa71092 | ||
|
|
e86cf9a722 | ||
|
|
5b9411b9da | ||
|
|
179131893e | ||
|
|
8ef7178ddf | ||
|
|
413a84836b | ||
|
|
702ab261a2 | ||
|
|
5fe74bec3b | ||
|
|
a47a2bceff | ||
|
|
ac7abfdd74 | ||
|
|
595598533a | ||
|
|
8416fecfd8 | ||
|
|
9fd01f6af4 | ||
|
|
d9102d2703 | ||
|
|
53f991df0f | ||
|
|
eeb8f1b694 | ||
|
|
e3cdeb3244 | ||
|
|
1834c087e7 | ||
|
|
113ab6379a | ||
|
|
0a2de034e8 | ||
|
|
949821c57b | ||
|
|
13a9198013 | ||
|
|
b8a69f11d9 | ||
|
|
af5441eb82 | ||
|
|
b66fad81cc | ||
|
|
1dc16fc12b | ||
|
|
6eb2e21144 | ||
|
|
4f7a0e014b | ||
|
|
f7c44945b7 | ||
|
|
13b4306f40 | ||
|
|
6c9ffa4d3c | ||
|
|
ffd27b5705 | ||
|
|
3181c4e59f | ||
|
|
ec160f0d59 | ||
|
|
bf95b75489 | ||
|
|
405dbb1cbe | ||
|
|
1412d16f6e | ||
|
|
b17483b7dd | ||
|
|
c91eda8d78 | ||
|
|
6da2eecb90 | ||
|
|
8548956370 | ||
|
|
34d18cc182 | ||
|
|
58ad8399cf | ||
|
|
659f7617f5 | ||
|
|
8834f929aa | ||
|
|
36a86c67d0 | ||
|
|
a19dbfaddf | ||
|
|
40d48eae76 | ||
|
|
f7b9709907 | ||
|
|
2944a4bcbd | ||
|
|
b84e22c4ea | ||
|
|
f9298d30ca | ||
|
|
e025f335dc | ||
|
|
a6d840d6be | ||
|
|
cd752fea14 | ||
|
|
3f1d06da32 | ||
|
|
bc9aec9076 | ||
|
|
b7639f5b22 | ||
|
|
f7c6bd4eab | ||
|
|
ee529669d2 | ||
|
|
8bd071d9a9 | ||
|
|
dc27a30800 | ||
|
|
489ed20331 | ||
|
|
aa10f42913 | ||
|
|
72f8fcbb5b | ||
|
|
876cfdfb2d | ||
|
|
4b67008488 | ||
|
|
ae88e7c96e | ||
|
|
7a1e2e0c72 | ||
|
|
d2832ee43b | ||
|
|
0c6a1307da | ||
|
|
cae3b8ec1e | ||
|
|
6fdd01a8eb | ||
|
|
fe0bc2f3eb | ||
|
|
0318749b56 | ||
|
|
854ff27e56 | ||
|
|
568eb730bc | ||
|
|
29845dd383 | ||
|
|
44433c2d42 | ||
|
|
d65d1028a7 | ||
|
|
e5cf06101a | ||
|
|
6a8bfdeb76 | ||
|
|
70195bec2d | ||
|
|
c2490d4831 | ||
|
|
80b5a370b0 | ||
|
|
318ca6be38 | ||
|
|
95efd72679 | ||
|
|
fa01cc828a | ||
|
|
5683880de6 | ||
|
|
ba2c0627be | ||
|
|
e09a012016 | ||
|
|
bd1e658080 | ||
|
|
23e0eb0aa7 | ||
|
|
50e269470e | ||
|
|
4fbdd3f8a0 | ||
|
|
26b475753e | ||
|
|
8aa6e6050a | ||
|
|
27bdc65e46 | ||
|
|
bd5440a102 | ||
|
|
fcde17583c | ||
|
|
69099b060e | ||
|
|
17f317665f | ||
|
|
010a2961cb | ||
|
|
e29bfaf5cc | ||
|
|
9f5276dd2b | ||
|
|
fe3a999cb2 | ||
|
|
bd6070e2b0 | ||
|
|
c1b6f1ef24 | ||
|
|
6cf63d1366 | ||
|
|
8e59118271 | ||
|
|
8ddb5908f2 | ||
|
|
3d47814c5e | ||
|
|
57807ff337 | ||
|
|
5ffc8206b0 | ||
|
|
0a2129a5ea | ||
|
|
7561f73f16 | ||
|
|
25f20ca7d7 | ||
|
|
dc6ae7a380 | ||
|
|
0a32005cc8 | ||
|
|
f2980b13fb | ||
|
|
148562786d | ||
|
|
16b8afab20 | ||
|
|
dae1ab8c0a | ||
|
|
8d38887ca1 | ||
|
|
3ff1106526 | ||
|
|
a24b29deec | ||
|
|
8829ef6bab | ||
|
|
c64170ef33 | ||
|
|
e373a1e0bb | ||
|
|
9ecff0f64d | ||
|
|
19037daa05 | ||
|
|
1893f769a8 | ||
|
|
95db7259d4 | ||
|
|
4ba19d5207 | ||
|
|
622b62f58d | ||
|
|
d2b701c001 | ||
|
|
49efb371f9 | ||
|
|
7244739e26 | ||
|
|
e9be5e8462 | ||
|
|
4edac15dce | ||
|
|
f576311532 | ||
|
|
5314433b70 | ||
|
|
ce4d814819 | ||
|
|
5f4e582660 | ||
|
|
c51039a5e3 | ||
|
|
96bb2312cb | ||
|
|
5372604170 | ||
|
|
57c6641683 | ||
|
|
50d118c3aa | ||
|
|
6159d92f74 | ||
|
|
23c5040ce5 | ||
|
|
efe45bac63 | ||
|
|
f2fc68acde | ||
|
|
b65dc9a827 | ||
|
|
b7d87f7d07 | ||
|
|
b8f5576a51 | ||
|
|
90c1e65adf | ||
|
|
53891ee98e | ||
|
|
24822bd9ac | ||
|
|
10e4e8354b | ||
|
|
789ce297e9 | ||
|
|
40529d718c | ||
|
|
16771f37d5 | ||
|
|
d536b4b62a | ||
|
|
2e5ce4a303 | ||
|
|
ebb9e252d8 | ||
|
|
2d78ef0922 | ||
|
|
1c30948ef9 | ||
|
|
c8b5003b86 | ||
|
|
aad2433440 | ||
|
|
140917ba37 | ||
|
|
60f7759c5e | ||
|
|
a55cbff865 | ||
|
|
1c2e1755cb | ||
|
|
e10873dd9c | ||
|
|
1cd42d4e47 | ||
|
|
d47671e5b6 | ||
|
|
a40377fac1 | ||
|
|
b3ada161cf | ||
|
|
515e9e34f4 | ||
|
|
25f4f943ae | ||
|
|
9379c036d5 | ||
|
|
fb29b8426a | ||
|
|
677bd05369 | ||
|
|
1c6113eb97 | ||
|
|
455b7951b3 | ||
|
|
79206b1fc4 | ||
|
|
3508b33f8d | ||
|
|
7d7cd2b4e2 | ||
|
|
c346e31d50 | ||
|
|
260cb17794 | ||
|
|
00cadde425 | ||
|
|
0f31a9918a | ||
|
|
9a12f6371c | ||
|
|
e30755ddeb | ||
|
|
bb00ef40b6 | ||
|
|
e84b633abd | ||
|
|
6fddabf48c | ||
|
|
9b34f000a8 | ||
|
|
b7359e7b0d | ||
|
|
f7912991a8 | ||
|
|
33b6c88fd5 | ||
|
|
e085c866dc | ||
|
|
dd70a3f325 | ||
|
|
619699269b | ||
|
|
ea29760bd2 | ||
|
|
8b5d6e83e7 | ||
|
|
073ef3784f | ||
|
|
6700cd0aa6 | ||
|
|
7fec42c12f | ||
|
|
45cbc8ca70 | ||
|
|
342eec5c2f | ||
|
|
89adc0717a | ||
|
|
ceace03646 | ||
|
|
7827951c8c | ||
|
|
539a23041e | ||
|
|
6104d688d9 | ||
|
|
082b6f818a | ||
|
|
dcfbdc7d29 | ||
|
|
4186e73c13 | ||
|
|
79ecfd994a | ||
|
|
b43b670195 | ||
|
|
6a4793bba8 | ||
|
|
0c2aad98a3 | ||
|
|
73cd9951af | ||
|
|
5bfc41d475 | ||
|
|
1741fa7e0f | ||
|
|
3ff7850dae | ||
|
|
a841cfeec3 | ||
|
|
22a2bf73fa | ||
|
|
9323cbd5d6 | ||
|
|
98c505a16b | ||
|
|
d422e16aad | ||
|
|
6a9fb4fcec | ||
|
|
45a52e21f0 | ||
|
|
7ed2052707 | ||
|
|
c3317f748e | ||
|
|
8a60c915cf | ||
|
|
622e110f0a | ||
|
|
688a09396d | ||
|
|
c1140ed7a8 | ||
|
|
9e6ca617de | ||
|
|
d316a08c26 | ||
|
|
14527c69d1 | ||
|
|
5f33cf32f4 | ||
|
|
a615339b48 | ||
|
|
1a0aefeb63 | ||
|
|
e839386283 | ||
|
|
36d187ee1f | ||
|
|
edf372e8b6 | ||
|
|
73119f96aa | ||
|
|
defc987328 | ||
|
|
2adf593fb8 | ||
|
|
f801a08974 | ||
|
|
eabfd82a5d | ||
|
|
ec7779fed6 | ||
|
|
bcbc080350 | ||
|
|
9a79ba25ef | ||
|
|
0c6d259a0a | ||
|
|
dcdaabc747 | ||
|
|
c7ebe4aa63 | ||
|
|
b727626f19 | ||
|
|
5ca43c1a3d | ||
|
|
a18c878a56 | ||
|
|
e275a34f6a | ||
|
|
84fcc7712b | ||
|
|
3e32c226c0 | ||
|
|
499061918d | ||
|
|
8d129c0209 | ||
|
|
ae700062b6 | ||
|
|
07edbd2c42 | ||
|
|
a5990de800 | ||
|
|
fd4f12aac8 | ||
|
|
fbf44d3219 | ||
|
|
f98f56af41 | ||
|
|
578bd606cc | ||
|
|
a203690f07 | ||
|
|
122895e1d6 | ||
|
|
2ba1b5e206 | ||
|
|
28d5de17e1 | ||
|
|
56f9366aa5 | ||
|
|
d55893aa6d | ||
|
|
e4005ac76c | ||
|
|
fee07efabb | ||
|
|
cd6537c4d3 | ||
|
|
f317b9b9a6 | ||
|
|
a07b015679 | ||
|
|
c15c13876a | ||
|
|
bd27669bec | ||
|
|
0de3a6ece4 | ||
|
|
6dc4aacf05 | ||
|
|
7006a4ee14 | ||
|
|
d57310bbc0 | ||
|
|
946d8ef4f2 | ||
|
|
01da5266a0 | ||
|
|
0ebfb30192 | ||
|
|
b4e86f0809 | ||
|
|
94331fde6d | ||
|
|
ff41818a84 | ||
|
|
15d30a2d35 | ||
|
|
f8bf1bb0ab | ||
|
|
309b5d4803 | ||
|
|
24a2ea6cef | ||
|
|
c6cc731bf0 | ||
|
|
2c3f38f522 | ||
|
|
d7d69af12f | ||
|
|
836cf3a1b8 | ||
|
|
2fc01137b7 | ||
|
|
0eaf08541b | ||
|
|
708f45568b | ||
|
|
4ad190549a | ||
|
|
60e6ed67e3 | ||
|
|
9744019341 | ||
|
|
727021306d | ||
|
|
e76f820cb5 | ||
|
|
ada50e418c | ||
|
|
7c683336fb | ||
|
|
24cc93ac70 | ||
|
|
db5999a639 | ||
|
|
0469d4374d | ||
|
|
d4cb15179f | ||
|
|
9c41ce9f1c | ||
|
|
e6af18f0bb | ||
|
|
454c9dcb33 | ||
|
|
9dc588231f | ||
|
|
0d3dca56ff | ||
|
|
699b53964e | ||
|
|
786dbcca24 | ||
|
|
3a7d2c3874 | ||
|
|
6c0c1b1341 | ||
|
|
9fec8a41e4 | ||
|
|
54055c1ff8 | ||
|
|
ba1dc49294 | ||
|
|
ca3414bda0 | ||
|
|
92394598c7 | ||
|
|
8a7d2b53e0 | ||
|
|
0e0c8ed8d7 | ||
|
|
3fdf249f4b | ||
|
|
75d196a1f4 | ||
|
|
f132e2b4ad | ||
|
|
ffebfd84fd | ||
|
|
780c2bc594 | ||
|
|
7d4ec19bca | ||
|
|
79f4d9a13b | ||
|
|
f3a238ccbc | ||
|
|
7e0723a7fc | ||
|
|
62f11ab747 | ||
|
|
cdee1c2911 | ||
|
|
01afd09ca2 | ||
|
|
6985df3aca | ||
|
|
59836f8775 | ||
|
|
e802bba5f9 | ||
|
|
25106e97f3 | ||
|
|
18f3284bf2 | ||
|
|
8b96741e8e | ||
|
|
6c63d32253 | ||
|
|
9ca1b754a5 | ||
|
|
3319c3eeea | ||
|
|
ba444adb67 | ||
|
|
d3c2b8b2aa | ||
|
|
ea59692b82 | ||
|
|
5f6beae22f | ||
|
|
8b81d270bc | ||
|
|
30d3982d25 | ||
|
|
d965d21ada | ||
|
|
805716ed68 | ||
|
|
da53452a53 | ||
|
|
ad7910c025 | ||
|
|
60997b463d | ||
|
|
c955e29805 | ||
|
|
33a1de7828 | ||
|
|
c2d1eea7d0 | ||
|
|
42d798a3de | ||
|
|
77e6795432 | ||
|
|
9057ddeb7c | ||
|
|
998f0da3d8 | ||
|
|
5decc61fc8 | ||
|
|
d65d47b517 | ||
|
|
ede5661da3 | ||
|
|
aa844b63d0 | ||
|
|
3f1c34f557 | ||
|
|
58749b1dd6 | ||
|
|
069440be15 | ||
|
|
24ebcc72f6 | ||
|
|
ed15f7cd73 | ||
|
|
fa1a7687e5 | ||
|
|
0d44e3eb7c | ||
|
|
4e9ebf48a8 | ||
|
|
b77daffc98 | ||
|
|
45cb3f9696 | ||
|
|
e564fa7f08 | ||
|
|
cb38e50ac1 | ||
|
|
83b7c34b00 | ||
|
|
a84a3fc5e1 | ||
|
|
67f55b4606 | ||
|
|
4a8edf44e6 | ||
|
|
481a80a3ce | ||
|
|
4986db5c6a | ||
|
|
53b5dfe39e | ||
|
|
c9b8c2d3a9 | ||
|
|
6f03953268 | ||
|
|
6ec3523748 | ||
|
|
9c12eb671d | ||
|
|
16b321e0a5 | ||
|
|
524cfafbbe | ||
|
|
c630b60ca9 | ||
|
|
a1417875a2 | ||
|
|
2f84e9f4ec | ||
|
|
a38af0949b | ||
|
|
70742e6aba | ||
|
|
6ebfbd241a | ||
|
|
ec85e2eb49 | ||
|
|
756158a735 | ||
|
|
6b3c4697fc | ||
|
|
ce33090c0d | ||
|
|
dd20fc7e71 | ||
|
|
641cc4013e | ||
|
|
dbb3e8308b | ||
|
|
cde61187c2 | ||
|
|
287c0b3125 | ||
|
|
26e1586288 | ||
|
|
753ba835f1 | ||
|
|
e68e253d16 | ||
|
|
eb1e678820 | ||
|
|
4038d94c5a | ||
|
|
a210cfd1ab | ||
|
|
f7d60c61b6 | ||
|
|
61feb47da8 | ||
|
|
602b21ac7d | ||
|
|
de129b733d | ||
|
|
7928a3a0d6 | ||
|
|
f079a9f308 | ||
|
|
1b9e77fe83 | ||
|
|
1b14229da4 | ||
|
|
a875e93f2e | ||
|
|
f131961d8b | ||
|
|
f781d0b9fe | ||
|
|
0710bdfead | ||
|
|
1bc22727d5 | ||
|
|
3d4fdea8d5 | ||
|
|
31bad6df49 | ||
|
|
58508c3404 | ||
|
|
b973ed7e4f | ||
|
|
544ecdc1be | ||
|
|
ab0ef91f24 | ||
|
|
36aef07738 | ||
|
|
df9b8fed2e | ||
|
|
0c2efeb7a7 | ||
|
|
d6c9eb0791 | ||
|
|
d530133f62 | ||
|
|
7b524bc557 | ||
|
|
854ad293c5 | ||
|
|
1e2fdac891 | ||
|
|
50512fef45 | ||
|
|
37315903fa | ||
|
|
b4af126bc3 | ||
|
|
ad17b2070a | ||
|
|
9d4a85e461 | ||
|
|
435eeeb85d | ||
|
|
a513893a19 | ||
|
|
7ab366a735 | ||
|
|
38bd037d99 | ||
|
|
c2d46a084e | ||
|
|
d426a795c3 | ||
|
|
09ecd1cbec | ||
|
|
03b56ae5b9 | ||
|
|
5284a2144d | ||
|
|
1a87bbd830 | ||
|
|
458f12f8a2 | ||
|
|
642ffe5151 | ||
|
|
600e3fe853 | ||
|
|
29668556db | ||
|
|
af0db5916d | ||
|
|
7db5ec1269 | ||
|
|
a5207e7d57 | ||
|
|
b724757b8a | ||
|
|
1a8eaf8b93 | ||
|
|
1d689d1e27 | ||
|
|
989443b4e3 | ||
|
|
2d457ae26a | ||
|
|
bce96df177 | ||
|
|
704b29e686 | ||
|
|
abaef49670 | ||
|
|
d1e54174c7 | ||
|
|
72d30974ad | ||
|
|
c9de062ef5 | ||
|
|
aa350b7139 | ||
|
|
34437550d4 | ||
|
|
15bec1e28c | ||
|
|
70bae1b267 | ||
|
|
c650dc092e | ||
|
|
bf52fa6f57 | ||
|
|
8ac731788d | ||
|
|
b77908ce58 | ||
|
|
1b032ab618 | ||
|
|
41bf010cc1 | ||
|
|
91a10bf122 | ||
|
|
42a5cf70e6 | ||
|
|
a64c7abdcc | ||
|
|
feac841aad | ||
|
|
6568c56c85 | ||
|
|
42e0714801 | ||
|
|
47007c3331 | ||
|
|
64e09f7ca7 | ||
|
|
39cbd5f1d6 | ||
|
|
a4af9f2a4c | ||
|
|
146d9a688e | ||
|
|
b2cd6d9113 | ||
|
|
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 | ||
|
|
87a5646ad0 | ||
|
|
30aa83c5b2 | ||
|
|
e7a0ce57aa | ||
|
|
06cd94b12d | ||
|
|
2d709a48e6 | ||
|
|
ab64ae7fe7 | ||
|
|
acd21eae4c | ||
|
|
364d9b5a8a | ||
|
|
8a8b1c4b2b | ||
|
|
fc68acd0ab | ||
|
|
98ea3fa74e | ||
|
|
fa7d36da9b |
+48
-6
@@ -1,11 +1,53 @@
|
||||
data/*
|
||||
*.pyc
|
||||
*.egg/
|
||||
*.egg-info/
|
||||
*.py[cod]
|
||||
|
||||
# vi(m) swap files:
|
||||
*.sw?
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Packages
|
||||
.cache/
|
||||
*.egg
|
||||
*.eggs
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
eggs
|
||||
parts
|
||||
bin
|
||||
var
|
||||
sdist
|
||||
develop-eggs
|
||||
.installed.cfg
|
||||
lib
|
||||
lib64
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
.coverage
|
||||
.eggs/
|
||||
build/
|
||||
.tox
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
htmlcov
|
||||
junit.xml
|
||||
features/output
|
||||
dummy
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
||||
# Mr Developer
|
||||
.mr.developer.cfg
|
||||
.project
|
||||
.pydevproject
|
||||
|
||||
pgpass
|
||||
scm-source.json
|
||||
|
||||
# Sphinx-generated documentation
|
||||
docs/build/
|
||||
docs/source/_static/
|
||||
docs/source/_templates/
|
||||
|
||||
+108
-8
@@ -1,14 +1,114 @@
|
||||
sudo: false
|
||||
dist: trusty
|
||||
language: python
|
||||
python:
|
||||
- "2.7"
|
||||
- "3.3"
|
||||
- "3.4"
|
||||
- "3.4" # 2.7 and 3.5 are preinstalled by default
|
||||
env:
|
||||
global:
|
||||
- ETCDVERSION=3.0.15 ZKVERSION=3.4.9 CONSULVERSION=0.7.2
|
||||
- PYVERSIONS="2.7 3.4 3.5"
|
||||
matrix:
|
||||
- TEST_SUITE="python setup.py"
|
||||
- DCS="etcd" TEST_SUITE="behave"
|
||||
- DCS="exhibitor" TEST_SUITE="behave"
|
||||
- DCS="consul" TEST_SUITE="behave"
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/mycache
|
||||
before_cache:
|
||||
- |
|
||||
rm -fr $HOME/mycache/python*
|
||||
for pv in $PYVERSIONS; do
|
||||
if [[ $TEST_SUITE != "behave" || $pv != "3.4" ]]; then
|
||||
fpv=$(basename $(readlink $HOME/virtualenv/python${pv}))
|
||||
mv $HOME/virtualenv/${fpv} $HOME/mycache/${fpv}
|
||||
fi
|
||||
done
|
||||
install:
|
||||
- if [[ $TRAVIS_PYTHON_VERSION == 2* ]]; then pip install -r requirements-py2.txt --use-mirrors; fi
|
||||
- if [[ $TRAVIS_PYTHON_VERSION == 3* ]]; then pip install -r requirements-py3.txt --use-mirrors; fi
|
||||
- pip install coveralls
|
||||
- |
|
||||
set -e
|
||||
|
||||
if [[ $TEST_SUITE == "behave" ]]; then
|
||||
function get_consul() {
|
||||
CC=~/mycache/consul_${CONSULVERSION}
|
||||
if [[ ! -x $CC ]]; then
|
||||
curl -L https://releases.hashicorp.com/consul/${CONSULVERSION}/consul_${CONSULVERSION}_linux_amd64.zip \
|
||||
| gunzip > $CC
|
||||
[[ ${PIPESTATUS[0]} == 0 ]] || return 1
|
||||
chmod +x $CC
|
||||
fi
|
||||
ln -s $CC consul
|
||||
}
|
||||
|
||||
function get_etcd() {
|
||||
EC=~/mycache/etcd_${ETCDVERSION}
|
||||
if [[ ! -x $EC ]]; then
|
||||
curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz \
|
||||
| tar xz -C . --strip=1 --wildcards --no-anchored etcd
|
||||
[[ ${PIPESTATUS[0]} == 0 ]] || return 1
|
||||
mv etcd $EC
|
||||
fi
|
||||
ln -s $EC etcd
|
||||
}
|
||||
|
||||
function get_exhibitor() {
|
||||
ZC=~/mycache/zookeeper-${ZKVERSION}
|
||||
if [[ ! -d $ZC ]]; then
|
||||
curl -L http://www.apache.org/dist/zookeeper/zookeeper-${ZKVERSION}/zookeeper-${ZKVERSION}.tar.gz | tar xz
|
||||
[[ ${PIPESTATUS[0]} == 0 ]] || return 1
|
||||
mv zookeeper-${ZKVERSION}/conf/zoo_sample.cfg zookeeper-${ZKVERSION}/conf/zoo.cfg
|
||||
mv zookeeper-${ZKVERSION} $ZC
|
||||
fi
|
||||
$ZC/bin/zkServer.sh start
|
||||
# following lines are 'emulating' exhibitor REST API
|
||||
while true; do
|
||||
echo -e 'HTTP/1.0 200 OK\nContent-Type: application/json\n\n{"servers":["127.0.0.1"],"port":2181}' \
|
||||
| nc -l 8181 &> /dev/null
|
||||
done&
|
||||
}
|
||||
|
||||
attempt_num=1
|
||||
until get_${DCS}; do
|
||||
[[ $attempt_num -ge 3 ]] && exit 1
|
||||
echo "Attempt $attempt_num failed! Trying again in $attempt_num seconds..."
|
||||
sleep $(( attempt_num++ ))
|
||||
done
|
||||
fi
|
||||
|
||||
for pv in $PYVERSIONS; do
|
||||
if [[ $TEST_SUITE != "behave" || $pv != "3.4" ]]; then
|
||||
fpv=$(basename $(readlink $HOME/virtualenv/python$pv))
|
||||
if [[ -d ~/mycache/${fpv} ]]; then
|
||||
mv ~/virtualenv/${fpv} ~/virtualenv/${fpv}.bckp
|
||||
mv ~/mycache/${fpv} ~/virtualenv/${fpv}
|
||||
fi
|
||||
source ~/virtualenv/python${pv}/bin/activate
|
||||
# explicitly install all needed python modules to cache them
|
||||
for p in '-r requirements.txt' 'behave codacy-coverage coverage coveralls flake8 mock pytest-cov pytest setuptools'; do
|
||||
pip install $p --upgrade
|
||||
done
|
||||
fi
|
||||
done
|
||||
script:
|
||||
- python setup.py test
|
||||
- python setup.py flake8
|
||||
- |
|
||||
for pv in $PYVERSIONS; do
|
||||
source ~/virtualenv/python${pv}/bin/activate
|
||||
|
||||
if [[ $TEST_SUITE != "behave" ]]; then
|
||||
echo Running unit tests using python${pv}
|
||||
$TEST_SUITE test
|
||||
$TEST_SUITE flake8
|
||||
elif [[ $pv != "3.4" ]]; then
|
||||
echo Running acceptance tests using python${pv}
|
||||
if ! PATH=.:/usr/lib/postgresql/9.6/bin:$PATH $TEST_SUITE; then
|
||||
# output all log files when tests are failing
|
||||
grep . features/output/*/*postgres?.*
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
set +e
|
||||
after_success:
|
||||
- coveralls
|
||||
- if [[ $TEST_SUITE != "behave" ]]; then python-codacy-coverage -r coverage.xml; fi
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# for github.com
|
||||
approvals:
|
||||
groups:
|
||||
zalando:
|
||||
minimum: 2
|
||||
from:
|
||||
orgs:
|
||||
- "zalando"
|
||||
# team should be valid team id in team service https://teams.auth.zalando.com/api/teams/:id
|
||||
X-Zalando-Team: "acid"
|
||||
# type should be one of [code, doc, config, tools, secrets]
|
||||
# code will be the default value, if X-Zalando-Type is not found in .zappr.yml
|
||||
X-Zalando-Type: code
|
||||
+46
-23
@@ -1,36 +1,59 @@
|
||||
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
|
||||
## It has all the necessary components to play/debug with a single node appliance, running etcd
|
||||
FROM ubuntu:14.04
|
||||
MAINTAINER Feike Steenbergen <feike.steenberge[email protected]>
|
||||
FROM ubuntu:16.04
|
||||
MAINTAINER Alexander Kukushkin <alexander.kukushki[email protected]>
|
||||
|
||||
# We need curl
|
||||
RUN apt-get update -y && apt-get install curl -y
|
||||
|
||||
# Add PGDG repositories
|
||||
RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list
|
||||
RUN curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
|
||||
RUN apt-get update -y
|
||||
RUN apt-get upgrade -y
|
||||
|
||||
ENV PGVERSION 9.4
|
||||
RUN apt-get install python python-yaml python-requests python-boto postgresql-${PGVERSION} python-dnspython python-kazoo python-pip -y
|
||||
RUN apt-get install python-dev postgresql-server-dev-${PGVERSION} -y
|
||||
RUN pip install python-etcd psycopg2
|
||||
RUN echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/01norecommend \
|
||||
&& echo 'APT::Install-Suggests "0";' >> /etc/apt/apt.conf.d/01norecommend
|
||||
|
||||
ENV PGVERSION 9.6
|
||||
ENV PATH /usr/lib/postgresql/${PGVERSION}/bin:$PATH
|
||||
RUN apt-get update -y \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get install -y curl jq haproxy zookeeper python-psycopg2 python-yaml python-requests \
|
||||
python-six python-click python-dateutil python-tzlocal python-urllib3 python-dnspython \
|
||||
python-pip python-setuptools python-kazoo python-prettytable python-wheel python \
|
||||
|
||||
ADD patroni.py /patroni.py
|
||||
ADD patroni/ /patroni
|
||||
&& export DISTRIB_CODENAME=$(sed -n 's/DISTRIB_CODENAME=//p' /etc/lsb-release) \
|
||||
&& echo "deb http://apt.postgresql.org/pub/repos/apt/ ${DISTRIB_CODENAME}-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||
&& echo "deb-src http://apt.postgresql.org/pub/repos/apt/ ${DISTRIB_CODENAME}-pgdg main" >> /etc/apt/sources.list.d/pgdg.list \
|
||||
&& curl -s -o - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - \
|
||||
|
||||
ENV ETCDVERSION 2.0.13
|
||||
RUN curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz | tar xz -C /bin --strip=1 --wildcards --no-anchored etcd etcdctl
|
||||
## Make sure we have a en_US.UTF-8 locale available
|
||||
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
|
||||
|
||||
&& apt-get update -y \
|
||||
&& apt-get install -y postgresql-contrib-${PGVERSION} \
|
||||
|
||||
# Remove the default cluster, which Debian stupidly starts right after installation of the packages
|
||||
&& pg_dropcluster --stop ${PGVERSION} main \
|
||||
&& pip install python-etcd==0.4.3 python-consul==0.6.1 --upgrade \
|
||||
|
||||
# Clean up
|
||||
&& apt-get remove -y python-pip python-setuptools \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf /var/lib/apt/lists/* /root/.cache
|
||||
|
||||
ENV ETCDVERSION 3.0.15
|
||||
RUN curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz \
|
||||
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl
|
||||
|
||||
ENV CONFDVERSION 0.11.0
|
||||
RUN curl -L https://github.com/kelseyhightower/confd/releases/download/v${CONFDVERSION}/confd-${CONFDVERSION}-linux-amd64 > /usr/local/bin/confd \
|
||||
&& chmod +x /usr/local/bin/confd
|
||||
|
||||
ADD patronictl.py patroni.py docker/entrypoint.sh /
|
||||
ADD patroni /patroni/
|
||||
ADD extras/confd /etc/confd
|
||||
RUN ln -s /patronictl.py /usr/local/bin/patronictl
|
||||
|
||||
### Setting up a simple script that will serve as an entrypoint
|
||||
RUN mkdir /data/ && touch /var/log/etcd.log /var/log/etcd.err /pgpass /patroni/postgres.yml
|
||||
RUN chown postgres:postgres -R /patroni/ /data/ /pgpass /var/log/etcd.* /patroni/postgres.yml
|
||||
ADD docker/entrypoint.sh /entrypoint.sh
|
||||
RUN mkdir /data/ && touch /pgpass /patroni.yml \
|
||||
&& chown postgres:postgres -R /patroni/ /data/ /pgpass /patroni.yml /etc/haproxy /var/run/ /var/lib/ /var/log/ \
|
||||
&& echo 1 > /etc/zookeeper/conf/myid
|
||||
|
||||
EXPOSE 4001 5432 2380
|
||||
EXPOSE 2379 5432 8008
|
||||
|
||||
ENTRYPOINT ["/bin/bash", "/entrypoint.sh"]
|
||||
USER postgres
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Alexander Kukushkin <[email protected]>
|
||||
Feike Steenbergen <[email protected]>
|
||||
Oleksii Kliukin <[email protected]>
|
||||
+67
-151
@@ -1,34 +1,74 @@
|
||||
|Build Status| |Coverage Status|
|
||||
|
||||
Patroni: A Template for PostgreSQL HA with ZooKeeper or etcd
|
||||
Patroni: A Template for PostgreSQL HA with ZooKeeper, etcd or Consul
|
||||
------------------------------------------------------------
|
||||
|
||||
Patroni was previously known as Governor.
|
||||
You can find a version of this documentation that is searchable and also easier to navigagate at `patroni.readthedocs.io <https://patroni.readthedocs.io>`__.
|
||||
|
||||
*There are many ways to run high availability with PostgreSQL. Here, we
|
||||
present a template for you to create your own customized, high-availability
|
||||
solution using Python and — for maximum accessibility — a distributed
|
||||
configuration store like ZooKeeper or etcd.*
|
||||
|
||||
Getting Started
|
||||
---------------
|
||||
There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__.
|
||||
|
||||
Patroni is a template for you to create your own customized, high-availability solution using Python and - for maximum accessibility - a distributed configuration store like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__ or `Consul <https://github.com/hashicorp/consul>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in the datacenter-or anywhere else-will hopefully find it useful.
|
||||
|
||||
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely.
|
||||
|
||||
**Note to Kubernetes users**: We're currently developing Patroni to be as useful as possible for teams running Kubernetes on top of Google Compute Engine; Patroni can be the HA solution for Postgres in such an environment. To this end, we've created a `Helm Chart <https://github.com/kubernetes/charts/tree/master/incubator/patroni>`__ that enables you to deploy a five-node Patroni cluster using a Kubernetes PetSet.
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
:depth: 1
|
||||
:backlinks: none
|
||||
|
||||
==============
|
||||
How Patroni Works
|
||||
==============
|
||||
|
||||
Patroni originated as a fork of `Governor <https://github.com/compose/governor>`__, the project from Compose. It includes plenty of new features.
|
||||
|
||||
For an example of a Docker-based deployment with Patroni, see `Spilo <https://github.com/zalando/spilo>`__, currently in use at Zalando.
|
||||
|
||||
For additional background info, see:
|
||||
|
||||
* `PostgreSQL HA with Kubernetes and Patroni <https://www.youtube.com/watch?v=iruaCgeG7qs>`__, talk by Josh Berkus at KubeCon 2016 (video)
|
||||
* `Feb. 2016 Zalando Tech blog post <https://tech.zalando.de/blog/zalandos-patroni-a-template-for-high-availability-postgresql/>`__
|
||||
|
||||
================
|
||||
Development Status
|
||||
================
|
||||
|
||||
Patroni is in active development and accepts contributions. See our `Contributing <https://github.com/zalando/patroni/blob/master/docs/CONTRIBUTING.rst>`__ section below for more details.
|
||||
|
||||
We report new releases information `here <https://github.com/zalando/patroni/releases>`__.
|
||||
|
||||
===========================
|
||||
Technical Requirements/Installation
|
||||
===========================
|
||||
|
||||
**For Mac**
|
||||
|
||||
To install requirements on a Mac, run the following:
|
||||
|
||||
::
|
||||
|
||||
brew install postgresql etcd haproxy libyaml python
|
||||
pip install psycopg2 pyyaml
|
||||
|
||||
===================
|
||||
Running and Configuring
|
||||
===================
|
||||
|
||||
To get started, do the following from different terminals:
|
||||
|
||||
::
|
||||
|
||||
> etcd --data-dir=data/etcd
|
||||
> ./patroni.py postgres0.yml
|
||||
> ./patroni.py postgres1.yml
|
||||
|
||||
From there, you will see a high-availability cluster start up. Test
|
||||
different settings in the YAML files to see how its behavior changes. Kill
|
||||
some of the components to see how the system behaves.
|
||||
You will then see a high-availability cluster start up. Test different settings in the YAML files to see how the cluster's behavior changes. Kill some of the components to see how the system behaves.
|
||||
|
||||
Add more ``postgres*.yml`` files to create an even larger cluster.
|
||||
|
||||
We provide a haproxy configuration, which will give your application a
|
||||
single endpoint for connecting to the cluster's leader. To configure,
|
||||
Patroni provides an `HAProxy <http://www.haproxy.org/>`__ configuration, which will give your application a single endpoint for connecting to the cluster's leader. To configure,
|
||||
run:
|
||||
|
||||
::
|
||||
@@ -39,153 +79,29 @@ run:
|
||||
|
||||
> psql --host 127.0.0.1 --port 5000 postgres
|
||||
|
||||
How Patroni Works
|
||||
-----------------
|
||||
|
||||
For a diagram of the high availability decision loop, review this PDF:
|
||||
`postgres-ha.pdf <https://github.com/zalando/patroni/blob/master/postgres-ha.pdf>`__
|
||||
|
||||
===============
|
||||
YAML Configuration
|
||||
------------------
|
||||
===============
|
||||
|
||||
For an example file, see ``postgres0.yml``. Regarding settings:
|
||||
Go `here <https://github.com/zalando/patroni/blob/master/docs/SETTINGS.rst>`__ for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/zalando/patroni/blob/master/postgres0.yml>`__.
|
||||
|
||||
- *ttl*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
|
||||
- *loop\_wait*: the number of seconds the loop will sleep
|
||||
=========================
|
||||
Environment Configuration
|
||||
=========================
|
||||
|
||||
- *restapi*:
|
||||
- *listen*: IP address + port that Patroni will listen to, to provide health-check information for haproxy.
|
||||
- *connect\_address*: IP address + port through which restapi is accessible.
|
||||
- *auth*: (optional) 'username:password' to protect dangerous REST API endpoints.
|
||||
- *certfile*: (optional) Specifies a file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
|
||||
- *keyfile*: (optional) Specifies a file with the secret key in the PEM format.
|
||||
|
||||
- *etcd*:
|
||||
- *scope*: the relative path used on etcd's HTTP API for this deployment; makes it possible to run multiple HA deployments from a single etcd.
|
||||
- *ttl*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
|
||||
- *host*: the host:port for the etcd endpoint.
|
||||
|
||||
- *zookeeper*:
|
||||
- *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.
|
||||
- *session\_timeout*: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process.
|
||||
- *reconnect\_timeout*: how long we should try to reconnect to ZooKeeper after a connection loss. After this timeout, assume that you no longer have a lock and restart in read-only mode.
|
||||
- *hosts*: list of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...']
|
||||
- *exhibitor*: if you are running a ZooKeeper cluster under the Exhibitor supervisory, the following section might interest you:
|
||||
- *poll\_interval*: how often the list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor
|
||||
- *port*: Exhibitor port.
|
||||
- *hosts*: initial list of Exhibitor (ZooKeeper) nodes in format: ['host1', 'host2', 'etc...' ]. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
|
||||
|
||||
- *postgresql*:
|
||||
- *name*: the name of the Postgres host. Must be unique for the cluster.
|
||||
- *listen*: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication.
|
||||
- *connect\_address*: IP address + port through which Postgres is accessible from other nodes and applications.
|
||||
- *data\_dir*: file path to initialize and store Postgres data files.
|
||||
- *maximum\_lag\_on\_failover*: the maximum bytes a follower may lag.
|
||||
- *use\_slots*: whether or not to use replication_slots. Must be False for PostgreSQL 9.3. You should comment out max_replication_slots before it becomes ineligible for leader status.
|
||||
|
||||
- *initdb*: List options to be passed on to initdb
|
||||
- *encoding*: default encoding for new databases
|
||||
- *locale*: default locale for new databases
|
||||
- *data-checksums* # When pg_rewind is needed on 9.3, this needs to be enabled
|
||||
|
||||
- *pg\_hba*: list of lines which should be added to pg\_hba.conf.
|
||||
- *- host all all 0.0.0.0/0 md5*.
|
||||
|
||||
- *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.
|
||||
|
||||
- *callbacks* callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. See scripts/aws.py as an example on how to write them.
|
||||
- *on\_start*: a script to run when the cluster starts.
|
||||
- *on\_stop*: a script to run when the cluster stops.
|
||||
- *on\_restart*: a script to run when the cluster restarts.
|
||||
- *on\_reload*: a script to run when configuration reload is triggered.
|
||||
- *on\_role\_change*: a script to run when the cluster is being promoted or demoted.
|
||||
|
||||
- *superuser*:
|
||||
- *password*: password for the Postgres user, set during initialization.
|
||||
|
||||
- *admin*:
|
||||
- *username*: admin username; user is created during initialization. It will have CREATEDB and CREATEROLE privileges.
|
||||
- *password*: admin password; user is created during initialization.
|
||||
|
||||
- *recovery\_conf*: additional configuration settings written to recovery.conf when configuring the follower.
|
||||
- *parameters*: list of configuration settings for Postgres. Many of these are required for replication to work.
|
||||
Go `here <https://github.com/zalando/patroni/blob/master/docs/ENVIRONMENT.rst>`__ for comprehensive information about configuring(overriding) settings via environment variables.
|
||||
|
||||
===============
|
||||
Replication Choices
|
||||
-------------------
|
||||
===============
|
||||
|
||||
Patroni uses Postgres' streaming replication. By default, this
|
||||
replication is asynchronous. For more information, see the `Postgres
|
||||
documentation on streaming
|
||||
replication <http://www.postgresql.org/docs/current/static/warm-standby.html#STREAMING-REPLICATION>`__.
|
||||
|
||||
Patroni's asynchronous replication configuration allows for
|
||||
``maximum_lag_on_failover`` settings. This setting ensures failover will
|
||||
not occur if a follower is more than a certain number of bytes behind
|
||||
the follower. This setting should be increased or decreased based on
|
||||
business requirements.
|
||||
|
||||
When asynchronous replication is not optimal for your use case, investigate
|
||||
how Postgres's `synchronous
|
||||
replication <http://www.postgresql.org/docs/current/static/warm-standby.html#SYNCHRONOUS-REPLICATION>`__
|
||||
works. Synchronous replication ensures consistency across a cluster by
|
||||
confirming that writes are written to a secondary before returning to
|
||||
the connecting client with a success. The cost of synchronous
|
||||
replication: reduced throughput on writes. This throughput will
|
||||
be entirely based on network performance. In hosted datacenter
|
||||
environments (like AWS, Rackspace, or any network you do not control),
|
||||
synchrous replication significantly increases the variability of write
|
||||
performance. If followers become inaccessible from the leader, the
|
||||
leader effectively becomes readonly.
|
||||
|
||||
To enable a simple synchronous replication test, add the follow lines to
|
||||
the ``parameters`` section of your YAML configuration files:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
synchronous_commit: "on"
|
||||
synchronous_standby_names: "*"
|
||||
|
||||
When using synchronous replication, use at least three Postgres data nodes
|
||||
to ensure write availability if one host fails.
|
||||
|
||||
Choosing your replication schema is dependent on your business
|
||||
considerations. Investigate both async and sync replication, as well as other
|
||||
HA solutions, to determine which solution is best for you.
|
||||
Patroni uses Postgres' streaming replication, which is asynchronous by default. Patroni's asynchronous replication configuration allows for ``maximum_lag_on_failover`` settings. This setting ensures failover will not occur if a follower is more than a certain number of bytes behind the leader. This setting should be increased or decreased based on business requirements. It's also possible to use synchronous replication for better durability guarantees. See `replication modes documentation <https://github.com/zalando/patroni/blob/master/docs/replication_modes.rst>` for details.
|
||||
|
||||
===============================
|
||||
Applications Should Not Use Superusers
|
||||
--------------------------------------
|
||||
===============================
|
||||
|
||||
When connecting from an application, always use a non-superuser. Patroni
|
||||
requires access to the database to function properly. By using a
|
||||
superuser from an application, you can potentially use the entire
|
||||
connection pool, including the connections reserved for superusers with
|
||||
the ``superuser_reserved_connections`` setting. If Patroni cannot access
|
||||
the Primary because the connection pool is full, behavior will be
|
||||
undesireable.
|
||||
|
||||
Requirements on a Mac
|
||||
---------------------
|
||||
|
||||
Run the following on a Mac to install requirements:
|
||||
|
||||
::
|
||||
|
||||
brew install postgresql etcd haproxy libyaml python
|
||||
pip install psycopg2 pyyaml
|
||||
|
||||
Notice
|
||||
------
|
||||
|
||||
There are many different ways to do HA with PostgreSQL: See `the
|
||||
PostgreSQL
|
||||
documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__
|
||||
for a complete list.
|
||||
|
||||
We call Patroni a "template" because it is far from being a one-size-fits-all
|
||||
or plug-and-play replication system. It will have its own caveats. Use wisely.
|
||||
When connecting from an application, always use a non-superuser. Patroni requires access to the database to function properly. By using a superuser from an application, you can potentially use the entire connection pool, including the connections reserved for superusers, with the ``superuser_reserved_connections`` setting. If Patroni cannot access the Primary because the connection pool is full, behavior will be undesirable.
|
||||
|
||||
.. |Build Status| image:: https://travis-ci.org/zalando/patroni.svg?branch=master
|
||||
:target: https://travis-ci.org/zalando/patroni
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
Failover
|
||||
========
|
||||
- When determining who should become master, include the minor version of PostgreSQL in the decision
|
||||
- Create a way to disable governance of a cluster, something like the existence of a "nogover" or "admin" file in PGDATA will stop governor from changing the cluster state
|
||||
- When determining who should become master, include the minor version of PostgreSQL in the decision.
|
||||
|
||||
Configuration
|
||||
==============
|
||||
- Provide a way to change pg_hba.conf of a running cluster on the Patroni level, without changing individual nodes.
|
||||
- Provide hooks to store and retrieve cluster-wide passwords without exposing them in a plain-text form to unauthorized users.
|
||||
|
||||
Documentation
|
||||
==============
|
||||
- Document how to run cascading replication and possibly initialize the cluster without an access to the master node.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# docker compose file for running a 3-node PostgreSQL cluster
|
||||
# with etcd as the SIS
|
||||
|
||||
patroni_etcd:
|
||||
container_name: patroni_etcd
|
||||
image: patroni
|
||||
command: --etcd
|
||||
|
||||
dbnode1:
|
||||
image: patroni
|
||||
hostname: dbnode1
|
||||
links:
|
||||
- patroni_etcd:patroni_etcd
|
||||
volumes:
|
||||
- ./patroni:/patroni
|
||||
env_file: docker/patroni-secrets.env
|
||||
environment:
|
||||
PATRONI_ETCD_HOST: patroni_etcd:2379
|
||||
PATRONI_NAME: dbnode1
|
||||
PATRONI_SCOPE: testcluster
|
||||
|
||||
dbnode2:
|
||||
image: patroni
|
||||
hostname: dbnode2
|
||||
links:
|
||||
- patroni_etcd:patroni_etcd
|
||||
volumes:
|
||||
- ./patroni:/patroni
|
||||
env_file: docker/patroni-secrets.env
|
||||
environment:
|
||||
PATRONI_ETCD_HOST: patroni_etcd:2379
|
||||
PATRONI_NAME: dbnode2
|
||||
PATRONI_SCOPE: testcluster
|
||||
|
||||
dbnode3:
|
||||
image: patroni
|
||||
hostname: dbnode3
|
||||
links:
|
||||
- patroni_etcd:patroni_etcd
|
||||
volumes:
|
||||
- ./patroni:/patroni
|
||||
env_file: docker/patroni-secrets.env
|
||||
environment:
|
||||
PATRONI_ETCD_HOST: patroni_etcd:2379
|
||||
PATRONI_NAME: dbnode3
|
||||
PATRONI_SCOPE: testcluster
|
||||
|
||||
haproxy:
|
||||
image: patroni
|
||||
links:
|
||||
- patroni_etcd:patroni_etcd
|
||||
ports:
|
||||
- "5000"
|
||||
- "5001"
|
||||
environment:
|
||||
PATRONI_ETCD_HOST: patroni_etcd:2379
|
||||
PATRONI_SCOPE: testcluster
|
||||
command: --confd
|
||||
@@ -67,24 +67,38 @@ while getopts "$optspec" optchar; do
|
||||
esac
|
||||
done
|
||||
|
||||
function random_name()
|
||||
{
|
||||
cat /dev/urandom | LC_ALL=C tr -dc 'a-zA-Z0-9' | head -c 8
|
||||
}
|
||||
|
||||
if [ -z ${PATRONI_SCOPE} ]
|
||||
then
|
||||
PATRONI_SCOPE=$(random_name)
|
||||
if [ -z ${PATRONI_SCOPE} ]; then
|
||||
PATRONI_SCOPE=$(cat /dev/urandom | LC_ALL=C tr -dc 'a-z0-9' | head -c 8)
|
||||
fi
|
||||
|
||||
etcd_container=$(docker run -P -d --name="${PATRONI_SCOPE}_etcd" "${DOCKER_IMAGE}" --etcd-only)
|
||||
etcd_container_ip=$(docker inspect --format '{{ .NetworkSettings.IPAddress }}' ${etcd_container})
|
||||
echo "The etcd container is ${etcd_container}, ip=${etcd_container_ip}"
|
||||
function docker_run()
|
||||
{
|
||||
local name=$1
|
||||
shift
|
||||
container=$(docker run -d --name=$name $*)
|
||||
container_ip=$(docker inspect --format '{{ .NetworkSettings.IPAddress }}' ${container})
|
||||
echo "Started container ${name}, ip=${container_ip}"
|
||||
}
|
||||
|
||||
for i in $(seq 1 "${MEMBERS}")
|
||||
do
|
||||
container_name=$(random_name)
|
||||
patroni_container=$(docker run -P -d --name="${PATRONI_SCOPE}_${container_name}" "${DOCKER_IMAGE}" --etcd="${etcd_container_ip}:4001" --name="${PATRONI_SCOPE}")
|
||||
patroni_container_ip=$(docker inspect --format '{{ .NetworkSettings.IPAddress }}' ${patroni_container})
|
||||
echo "Started Patroni container ${patroni_container}, ip=${patroni_container_ip}"
|
||||
|
||||
ETCD_CONTAINER="${PATRONI_SCOPE}_etcd"
|
||||
docker_run ${ETCD_CONTAINER} ${DOCKER_IMAGE} --etcd
|
||||
|
||||
DOCKER_ARGS="--link=${ETCD_CONTAINER}:${ETCD_CONTAINER} -e PATRONI_SCOPE=${PATRONI_SCOPE} -e PATRONI_ETCD_HOST=${ETCD_CONTAINER}:2379"
|
||||
PATRONI_ENV=$(sed 's/#.*//g' docker/patroni-secrets.env | sed -n 's/^PATRONI_.*$/-e &/p' | tr '\n' ' ')
|
||||
PATRONI_VOLUME="-v $(dirname $(dirname $(realpath $0)))/patroni:/patroni"
|
||||
|
||||
for i in $(seq 1 "${MEMBERS}"); do
|
||||
container_name=postgres${i}
|
||||
docker_run "${PATRONI_SCOPE}_${container_name}" \
|
||||
$PATRONI_VOLUME \
|
||||
$DOCKER_ARGS \
|
||||
$PATRONI_ENV \
|
||||
-e PATRONI_NAME=${container_name} \
|
||||
${DOCKER_IMAGE}
|
||||
done
|
||||
|
||||
docker_run "${PATRONI_SCOPE}_haproxy" \
|
||||
-p=5000 -p=5001 \
|
||||
$DOCKER_ARGS \
|
||||
${DOCKER_IMAGE} --confd
|
||||
|
||||
+58
-81
@@ -7,49 +7,52 @@ Usage: $0
|
||||
|
||||
Options:
|
||||
|
||||
--etcd ETCD Provide an external etcd to connect to
|
||||
--name NAME Give the cluster a specific name
|
||||
--etcd-only Do not run Patroni, run a standalone etcd
|
||||
--etcd Do not run Patroni, run a standalone etcd
|
||||
--confd Do not run Patroni, run a standalone confd
|
||||
--zookeeper Do not run Patroni, run a standalone zookeeper
|
||||
|
||||
Examples:
|
||||
|
||||
$0 --etcd=127.17.0.84:4001
|
||||
$0 --etcd-only
|
||||
$0 --etcd
|
||||
$0 --confd
|
||||
$0 --zookeeper
|
||||
$0
|
||||
$0 --name=true_scotsman
|
||||
__EOF__
|
||||
}
|
||||
|
||||
DOCKER_IP=$(hostname --ip-address)
|
||||
PATRONI_SCOPE=${PATRONI_SCOPE:-batman}
|
||||
ETCD_ARGS="--data-dir /tmp/etcd.data -advertise-client-urls=http://${DOCKER_IP}:2379 -listen-client-urls=http://0.0.0.0:2379 -listen-peer-urls=http://0.0.0.0:2380"
|
||||
|
||||
optspec=":vh-:"
|
||||
while getopts "$optspec" optchar; do
|
||||
case "${optchar}" in
|
||||
-)
|
||||
case "${OPTARG}" in
|
||||
etcd-only)
|
||||
exec etcd --data-dir /tmp/etcd.data \
|
||||
-advertise-client-urls=http://${DOCKER_IP}:4001 \
|
||||
-listen-client-urls=http://0.0.0.0:4001 \
|
||||
-listen-peer-urls=http://0.0.0.0:2380
|
||||
exit 0
|
||||
confd)
|
||||
haproxy -f /etc/haproxy/haproxy.cfg -p /var/run/haproxy.pid -D
|
||||
CONFD="confd -prefix=${PATRONI_NAMESPACE:-/service}/$PATRONI_SCOPE -interval=10 -backend"
|
||||
if [ ! -z ${PATRONI_ZOOKEEPER_HOSTS} ]; then
|
||||
while ! /usr/share/zookeeper/bin/zkCli.sh -server ${PATRONI_ZOOKEEPER_HOSTS} ls /; do
|
||||
sleep 1
|
||||
done
|
||||
exec $CONFD zookeeper -node ${PATRONI_ZOOKEEPER_HOSTS}
|
||||
else
|
||||
while ! curl -s ${PATRONI_ETCD_HOST}/v2/members | jq -r '.members[0].clientURLs[0]' | grep -q http; do
|
||||
sleep 1
|
||||
done
|
||||
exec $CONFD etcd -node $PATRONI_ETCD_HOST
|
||||
fi
|
||||
;;
|
||||
etcd)
|
||||
exec etcd $ETCD_ARGS
|
||||
;;
|
||||
zookeeper)
|
||||
exec /usr/share/zookeeper/bin/zkServer.sh start-foreground
|
||||
;;
|
||||
cheat)
|
||||
CHEAT=1
|
||||
;;
|
||||
name)
|
||||
PATRONI_SCOPE="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 ))
|
||||
;;
|
||||
name=*)
|
||||
PATRONI_SCOPE=${OPTARG#*=}
|
||||
;;
|
||||
etcd)
|
||||
ETCD_CLUSTER="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 ))
|
||||
;;
|
||||
etcd=*)
|
||||
ETCD_CLUSTER=${OPTARG#*=}
|
||||
;;
|
||||
help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -70,69 +73,43 @@ while getopts "$optspec" optchar; do
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z ${ETCD_CLUSTER} ]
|
||||
then
|
||||
etcd --data-dir /tmp/etcd.data \
|
||||
-advertise-client-urls=http://${DOCKER_IP}:4001 \
|
||||
-listen-client-urls=http://0.0.0.0:4001 \
|
||||
-listen-peer-urls=http://0.0.0.0:2380 > /var/log/etcd.log 2> /var/log/etcd.err &
|
||||
ETCD_CLUSTER="127.0.0.1:4001"
|
||||
## We start an etcd
|
||||
if [[ -z ${PATRONI_ETCD_HOST} && -z ${PATRONI_ZOOKEEPER_HOSTS} ]]; then
|
||||
etcd $ETCD_ARGS > /var/log/etcd.log 2> /var/log/etcd.err &
|
||||
export PATRONI_ETCD_HOST="127.0.0.1:2379"
|
||||
fi
|
||||
|
||||
cat > /patroni/postgres.yml <<__EOF__
|
||||
export PATRONI_SCOPE
|
||||
export PATRONI_NAME="${PATRONI_NAME:-${HOSTNAME}}"
|
||||
export PATRONI_RESTAPI_CONNECT_ADDRESS="${DOCKER_IP}:8008"
|
||||
export PATRONI_RESTAPI_LISTEN="0.0.0.0:8008"
|
||||
export PATRONI_admin_PASSWORD="${PATRONI_admin_PASSWORD:=admin}"
|
||||
export PATRONI_admin_OPTIONS="${PATRONI_admin_OPTIONS:-createdb, createrole}"
|
||||
export PATRONI_POSTGRESQL_CONNECT_ADDRESS="${DOCKER_IP}:5432"
|
||||
export PATRONI_POSTGRESQL_LISTEN="0.0.0.0:5432"
|
||||
export PATRONI_POSTGRESQL_DATA_DIR="data/${PATRONI_SCOPE}"
|
||||
export PATRONI_REPLICATION_USERNAME="${PATRONI_REPLICATION_USERNAME:-replicator}"
|
||||
export PATRONI_REPLICATION_PASSWORD="${PATRONI_REPLICATION_PASSWORD:-abcd}"
|
||||
export PATRONI_SUPERUSER_USERNAME="${PATRONI_SUPERUSER_USERNAME:-postgres}"
|
||||
export PATRONI_SUPERUSER_PASSWORD="${PATRONI_SUPERUSER_PASSWORD:-postgres}"
|
||||
export PATRONI_POSTGRESQL_PGPASS="$HOME/.pgpass"
|
||||
|
||||
cat > /patroni.yml <<__EOF__
|
||||
bootstrap:
|
||||
dcs:
|
||||
postgresql:
|
||||
use_pg_rewind: true
|
||||
|
||||
ttl: &ttl 30
|
||||
loop_wait: &loop_wait 10
|
||||
scope: &scope '${PATRONI_SCOPE}'
|
||||
namespace: 'patroni'
|
||||
restapi:
|
||||
listen: 0.0.0.0:8008
|
||||
connect_address: ${DOCKER_IP}:8008
|
||||
etcd:
|
||||
scope: *scope
|
||||
ttl: *ttl
|
||||
host: ${ETCD_CLUSTER}
|
||||
postgresql:
|
||||
name: postgresql_${DOCKER_IP//./_} ## Replication slots do not allow dots in their name
|
||||
scope: *scope
|
||||
listen: 0.0.0.0:5432
|
||||
connect_address: ${DOCKER_IP}:5432
|
||||
data_dir: data/postgresql0
|
||||
maximum_lag_on_failover: 1048576 # 1 megabyte in bytes
|
||||
pg_hba:
|
||||
- host all all 0.0.0.0/0 md5
|
||||
- hostssl all all 0.0.0.0/0 md5
|
||||
- host replication replicator ${DOCKER_IP}/16 md5
|
||||
replication:
|
||||
username: replicator
|
||||
password: rep-pass
|
||||
network: 127.0.0.1/32
|
||||
superuser:
|
||||
password: zalando
|
||||
restore: patroni/scripts/restore.py
|
||||
admin:
|
||||
username: admin
|
||||
password: admin
|
||||
parameters:
|
||||
archive_mode: "on"
|
||||
wal_level: hot_standby
|
||||
archive_command: 'true'
|
||||
max_wal_senders: 20
|
||||
listen_addresses: 0.0.0.0
|
||||
wal_keep_segments: 8
|
||||
archive_timeout: 1800s
|
||||
max_replication_slots: 20
|
||||
hot_standby: "on"
|
||||
__EOF__
|
||||
|
||||
cat /patroni/postgres.yml
|
||||
mkdir -p "$HOME/.config/patroni"
|
||||
[ -h "$HOME/.config/patroni/patronictl.yaml" ] || ln -s /patroni.yml "$HOME/.config/patroni/patronictl.yaml"
|
||||
|
||||
if [ ! -z $CHEAT ]
|
||||
then
|
||||
while :
|
||||
do
|
||||
sleep 60
|
||||
done
|
||||
else
|
||||
exec python /patroni.py /patroni/postgres.yml
|
||||
fi
|
||||
[ -z $CHEAT ] && exec python /patroni.py /patroni.yml
|
||||
|
||||
while true; do
|
||||
sleep 60
|
||||
done
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
PATRONI_RESTAPI_USERNAME=admin
|
||||
PATRONI_RESTAPI_PASSWORD=admin
|
||||
PATRONI_SUPERUSER_USERNAME=postgres
|
||||
PATRONI_SUPERUSER_PASSWORD=postgres
|
||||
PATRONI_REPLICATION_USERNAME=replicator
|
||||
PATRONI_REPLICATION_PASSWORD=replicate
|
||||
PATRONI_admin_PASSWORD=admin
|
||||
PATRONI_admin_OPTIONS=createdb,createrole
|
||||
@@ -0,0 +1,24 @@
|
||||
.. _contributing:
|
||||
|
||||
Contributing guidelines
|
||||
=======================
|
||||
|
||||
Wanna contribute to Patroni? Yay - here is how!
|
||||
|
||||
Reporting issues
|
||||
----------------
|
||||
|
||||
If you have a question about patroni or have a problem using it, please read the :ref:`README <readme>` before filing an issue.
|
||||
Also double check with the current issues on our `Issues Tracker <https://github.com/zalando/patroni/issues>`__.
|
||||
|
||||
Contributing a pull request
|
||||
---------------------------
|
||||
|
||||
1) Submit a comment to the relevant issue or create a new issue describing your proposed change.
|
||||
2) Do a fork, develop and test your code changes.
|
||||
3) Include documentation
|
||||
4) Submit a pull request.
|
||||
|
||||
You'll get feedback about your pull request as soon as possible.
|
||||
|
||||
Happy Patroni hacking ;-)
|
||||
@@ -0,0 +1,67 @@
|
||||
.. _environment:
|
||||
|
||||
==================================
|
||||
Environment Configuration Settings
|
||||
==================================
|
||||
|
||||
It is possible to override some of the configuration parameters defined in the Patroni configuration file using the system environment variables. This document lists all environment variables handled by Patroni. The values set via those variables always take precedence over the ones set in the Patroni configuration file.
|
||||
|
||||
Global/Universal
|
||||
----------------
|
||||
- **PATRONI\_CONFIGURATION**: it is possible to set the entire configuration for the Patroni via ``PATRONI_CONFIGURATION`` environment variable. In this case any other environment variables will not be considered!
|
||||
- **PATRONI\_NAME**: name of the node where the current instance of Patroni is running. Must be unique for the cluster.
|
||||
- **PATRONI\_NAMESPACE**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
|
||||
- **PATRONI\_SCOPE**: cluster name
|
||||
|
||||
Bootstrap configuration
|
||||
-----------------------
|
||||
It is possible to create new database users right after the successful initialization of a new cluster. This process is defined by the following variables:
|
||||
|
||||
- **PATRONI\_<username>\_PASSWORD='<password>'**
|
||||
- **PATRONI\_<username>\_OPTIONS='list,of,options'**
|
||||
|
||||
Example: defining ``PATRONI_admin_PASSWORD=strongpasswd`` and ``PATRONI_admin_OPTIONS='createrole,createdb'`` will cause creation of the user **admin** with the password **strongpasswd** that is allowed to create other users and databases.
|
||||
|
||||
Consul
|
||||
------
|
||||
- **PATRONI\_CONSUL\_HOST**: the host:port for the Consul endpoint.
|
||||
|
||||
Etcd
|
||||
----
|
||||
- **PATRONI\_ETCD\_HOST**: the host:port for the etcd endpoint.
|
||||
- **PATRONI\_ETCD\_URL**: url for the etcd, in format: http(s)://(username:password@)host:port
|
||||
- **PATRONI\_ETCD\_PROXY**: proxy url for the etcd. If you are connecting to the etcd using proxy, use this parameter instead of **PATRONI\_ETCD\_URL**
|
||||
- **PATRONI\_ETCD\_SRV**: Domain to search the SRV record(s) for cluster autodiscovery.
|
||||
- **PATRONI\_ETCD\_CACERT**: The ca certificate. If pressent it will enable validation.
|
||||
- **PATRONI\_ETCD\_CERT**: File with the client certificate
|
||||
- **PATRONI\_ETCD\_KEY**: File with the client key. Can be empty if the key is part of certificate.
|
||||
|
||||
Exhibitor
|
||||
---------
|
||||
- **PATRONI\_EXHIBITOR\_HOSTS**: initial list of Exhibitor (ZooKeeper) nodes in format: 'host1,host2,etc...'. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
|
||||
- **PATRONI\_EXHIBITOR\_PORT**: Exhibitor port.
|
||||
|
||||
PostgreSQL
|
||||
----------
|
||||
- **PATRONI\_POSTGRESQL\_LISTEN**: IP address + port that Postgres listens to. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node.
|
||||
- **PATRONI\_POSTGRESQL\_CONNECT\_ADDRESS**: IP address + port through which Postgres is accessible from other nodes and applications.
|
||||
- **PATRONI\_POSTGRESQL\_DATA\_DIR**: The location of the Postgres data directory, either existing or to be initialized by Patroni.
|
||||
- **PATRONI\_POSTGRESQL\_BIN_DIR**: Path to PostgreSQL binaries. (pg_ctl, pg_rewind, pg_basebackup, postgres) The default value is an empty string meaning that PATH environment variable will be used to find the executables.
|
||||
- **PATRONI\_POSTGRESQL\_PGPASS**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup and under some other circumstances. The location must be writable by Patroni.
|
||||
- **PATRONI\_REPLICATION\_USERNAME**: replication username; the user will be created during initialization. Replicas will use this user to access master via streaming replication
|
||||
- **PATRONI\_REPLICATION\_PASSWORD**: replication password; the user will be created during initialization.
|
||||
- **PATRONI\_SUPERUSER\_USERNAME**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres. Also this user is used by pg_rewind.
|
||||
- **PATRONI\_SUPERUSER\_PASSWORD**: password for the superuser, set during initialization (initdb).
|
||||
|
||||
REST API
|
||||
--------
|
||||
- **PATRONI\_RESTAPI\_CONNECT\_ADDRESS**: IP address and port to access the REST API.
|
||||
- **PATRONI\_RESTAPI\_LISTEN**: IP address and port that Patroni will listen to, to provide health-check information for HAProxy.
|
||||
- **PATRONI\_RESTAPI\_USERNAME**: Basic-auth username to protect unsafe REST API endpoints.
|
||||
- **PATRONI\_RESTAPI\_PASSWORD**: Basic-auth password to protect unsafe REST API endpoints.
|
||||
- **PATRONI\_RESTAPI\_CERTFILE**: Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
|
||||
- **PATRONI\_RESTAPI\_KEYFILE**: Specifies the file with the secret key in the PEM format.
|
||||
|
||||
ZooKeeper
|
||||
---------
|
||||
- **PATRONI\_ZOOKEEPER\_HOSTS**: comma separated list of ZooKeeper cluster members: "'host1:port1','host2:port2','etc...'". It is important to quote every single entity!
|
||||
@@ -0,0 +1,20 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS =
|
||||
SPHINXBUILD = sphinx-build
|
||||
SPHINXPROJ = Patroni
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
@@ -0,0 +1,90 @@
|
||||
.. _readme:
|
||||
|
||||
=================
|
||||
How Patroni Works
|
||||
=================
|
||||
|
||||
Patroni originated as a fork of `Governor <https://github.com/compose/governor>`__, the project from Compose. It includes plenty of new features.
|
||||
|
||||
For an example of a Docker-based deployment with Patroni, see `Spilo <https://github.com/zalando/spilo>`__, currently in use at Zalando.
|
||||
|
||||
For additional background info, see:
|
||||
|
||||
* `PostgreSQL HA with Kubernetes and Patroni <https://www.youtube.com/watch?v=iruaCgeG7qs>`__, talk by Josh Berkus at KubeCon 2016 (video)
|
||||
* `Feb. 2016 Zalando Tech blog post <https://tech.zalando.de/blog/zalandos-patroni-a-template-for-high-availability-postgresql/>`__
|
||||
|
||||
==================
|
||||
Development Status
|
||||
==================
|
||||
|
||||
Patroni is in active development and accepts contributions. See our :ref:`Contributing <contributing>` section below for more details.
|
||||
|
||||
We report new releases information :ref:`here <releases>`.
|
||||
|
||||
===================================
|
||||
Technical Requirements/Installation
|
||||
===================================
|
||||
|
||||
**For Mac**
|
||||
|
||||
To install requirements on a Mac, run the following:
|
||||
|
||||
::
|
||||
|
||||
brew install postgresql etcd haproxy libyaml python
|
||||
pip install psycopg2 pyyaml
|
||||
|
||||
=======================
|
||||
Running and Configuring
|
||||
=======================
|
||||
|
||||
To get started, do the following from different terminals:
|
||||
::
|
||||
|
||||
> etcd --data-dir=data/etcd
|
||||
> ./patroni.py postgres0.yml
|
||||
> ./patroni.py postgres1.yml
|
||||
|
||||
You will then see a high-availability cluster start up. Test different settings in the YAML files to see how the cluster's behavior changes. Kill some of the components to see how the system behaves.
|
||||
|
||||
Add more ``postgres*.yml`` files to create an even larger cluster.
|
||||
|
||||
Patroni provides an `HAProxy <http://www.haproxy.org/>`__ configuration, which will give your application a single endpoint for connecting to the cluster's leader. To configure,
|
||||
run:
|
||||
|
||||
::
|
||||
|
||||
> haproxy -f haproxy.cfg
|
||||
|
||||
::
|
||||
|
||||
> psql --host 127.0.0.1 --port 5000 postgres
|
||||
|
||||
==================
|
||||
YAML Configuration
|
||||
==================
|
||||
|
||||
Go :ref:`here <settings>` for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/zalando/patroni/blob/master/postgres0.yml>`__.
|
||||
|
||||
=========================
|
||||
Environment Configuration
|
||||
=========================
|
||||
|
||||
Go :ref:`here <environment>` for comprehensive information about configuring(overriding) settings via environment variables.
|
||||
|
||||
===================
|
||||
Replication Choices
|
||||
===================
|
||||
|
||||
Patroni uses Postgres' streaming replication, which is asynchronous by default. Patroni's asynchronous replication configuration allows for ``maximum_lag_on_failover`` settings. This setting ensures failover will not occur if a follower is more than a certain number of bytes behind the leader. This setting should be increased or decreased based on business requirements. It's also possible to use synchronous replication for better durability guarantees. See :ref:`replication modes documentation <replication_modes>` for details.
|
||||
|
||||
======================================
|
||||
Applications Should Not Use Superusers
|
||||
======================================
|
||||
|
||||
When connecting from an application, always use a non-superuser. Patroni requires access to the database to function properly. By using a superuser from an application, you can potentially use the entire connection pool, including the connections reserved for superusers, with the ``superuser_reserved_connections`` setting. If Patroni cannot access the Primary because the connection pool is full, behavior will be undesirable.
|
||||
|
||||
.. |Build Status| image:: https://travis-ci.org/zalando/patroni.svg?branch=master
|
||||
:target: https://travis-ci.org/zalando/patroni
|
||||
.. |Coverage Status| image:: https://coveralls.io/repos/zalando/patroni/badge.svg?branch=master
|
||||
:target: https://coveralls.io/r/zalando/patroni?branch=master
|
||||
@@ -0,0 +1,110 @@
|
||||
.. _settings:
|
||||
|
||||
===========================
|
||||
YAML Configuration Settings
|
||||
===========================
|
||||
|
||||
Global/Universal
|
||||
----------------
|
||||
- **name**: the name of the host. Must be unique for the cluster.
|
||||
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
|
||||
- **scope**: cluster name
|
||||
|
||||
Bootstrap configuration
|
||||
-----------------------
|
||||
- **dcs**: This section will be written into `/<namespace>/<scope>/config` of a given configuration store after initializing of new cluster. This is the global configuration for the cluster. If you want to change some parameters for all cluster nodes - just do it in DCS (or via Patroni API) and all nodes will apply this configuration.
|
||||
- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10
|
||||
- **ttl**: the TTL to acquire the leader lock. Think of it as the length of time before initiation of the automatic failover process. Default value: 30
|
||||
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
|
||||
- **master\_start\_timeout**: the amount of time a master is allowed to recover from failures before failover is triggered. Default is 300 seconds. When set to 0 failover is done immediately after a crash is detected if possible. When using asynchronous replication a failover can cause lost transactions. Best worst case failover time for master failure is: loop\_wait + master\_start\_timeout + loop\_wait, unless master\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
|
||||
- **synchronous\_mode**: turns on synchronous replication mode. In this mode a replica will be chosen as synchronous and only the latest leader and synchronous replica are able to participate in leader election. Synchronous mode makes sure that succesfully committed transactions will not be lost at failover, at the cost of losing availability for writes when Patroni cannot ensure transaction durability. See `replication modes documentation <https://github.com/zalando/patroni/blob/master/docs/replication_modes.rst>`__ for details.
|
||||
- **postgresql**:
|
||||
- **use\_pg\_rewind**:whether or not to use pg_rewind
|
||||
- **use\_slots**: whether or not to use replication_slots. Must be False for PostgreSQL 9.3. You should comment out max_replication_slots before it becomes ineligible for leader status.
|
||||
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower.
|
||||
- **parameters**: list of configuration settings for Postgres. Many of these are required for replication to work.
|
||||
- **initdb**: List options to be passed on to initdb.
|
||||
- **- data-checksums**: Must be enabled when pg_rewind is needed on 9.3.
|
||||
- **- encoding: UTF8**: default encoding for new databases.
|
||||
- **- locale: UTF8**: default locale for new databases.
|
||||
- **pg\_hba**: list of lines that you should add to pg\_hba.conf.
|
||||
- **- host all all 0.0.0.0/0 md5**.
|
||||
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
|
||||
- **users**: Some additional users users which needs to be created after initializing new cluster
|
||||
- **admin**: the name of user
|
||||
- **password: zalando**:
|
||||
- **options**: list of options for CREATE USER statement
|
||||
- **- createrole**
|
||||
- **- createdb**
|
||||
- **post_init**: An additional script that will be executed after initializing the cluster. The script receives a connection string URL (with the cluster superuser as a user name). The PGPASSFILE variable is set to the location of pgpass file.
|
||||
|
||||
Consul
|
||||
------
|
||||
- **host**: the host:port for the Consul endpoint.
|
||||
|
||||
Etcd
|
||||
----
|
||||
Most of the parameters are optional, but you have to specify one of the **host**, **url**, **proxy** or **srv**
|
||||
- **host**: the host:port for the etcd endpoint.
|
||||
- **url**: url for the etcd
|
||||
- **proxy**: proxy url for the etcd. If you are connecting to the etcd using proxy, use this parameter instead of **url**
|
||||
- **srv**: Domain to search the SRV record(s) for cluster autodiscovery.
|
||||
- **protocol**: (optional) http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
|
||||
- **username**: (optional) username for etcd authentication
|
||||
- **password**: (optional) password for etcd authentication.
|
||||
- **cacert**: (optional) The ca certificate. If pressent it will enable validation.
|
||||
- **cert**: (optional) file with the client certificate
|
||||
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
|
||||
|
||||
Exhibitor
|
||||
---------
|
||||
- **hosts**: initial list of Exhibitor (ZooKeeper) nodes in format: 'host1,host2,etc...'. This list updates automatically whenever the Exhibitor (ZooKeeper) cluster topology changes.
|
||||
- **poll\_interval**: how often the list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor
|
||||
- **port**: Exhibitor port.
|
||||
|
||||
.. _postgresql_settings:
|
||||
|
||||
PostgreSQL
|
||||
----------
|
||||
- **authentication**:
|
||||
- **superuser**:
|
||||
- **username**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres.
|
||||
- **password**: password for the superuser, set during initialization (initdb).
|
||||
- **replication**:
|
||||
- **username**: replication username; the user will be created during initialization. Replicas will use this user to access master via streaming replication
|
||||
- **password**: replication password; the user will be created during initialization.
|
||||
- **callbacks**: callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. (See scripts/aws.py as an example of how to write them.)
|
||||
- **on\_reload**: run this script when configuration reload is triggered.
|
||||
- **on\_restart**: run this script when the cluster restarts.
|
||||
- **on\_role\_change**: run this script when the cluster is being promoted or demoted.
|
||||
- **on\_start**: run this script when the cluster starts.
|
||||
- **on\_stop**: run this script when the cluster stops.
|
||||
- **connect\_address**: IP address + port through which Postgres is accessible from other nodes and applications.
|
||||
- **create\_replica\_methods**: an ordered list of the create methods for turning a Patroni node into a new replica. "basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured as its own config item.
|
||||
- **data\_dir**: The location of the Postgres data directory, either existing or to be initialized by Patroni.
|
||||
- **bin\_dir**: Path to PostgreSQL binaries. (pg_ctl, pg_rewind, pg_basebackup, postgres) The default value is an empty string meaning that PATH environment variable will be used to find the executables.
|
||||
- **listen**: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node.
|
||||
- **pgpass**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup, the post_init script and under some other circumstances. The location must be writable by Patroni.
|
||||
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower.
|
||||
- **custom_conf** : path to an optional custom ``postgresql.conf`` file, that will be used in place of ``postgresql.base.conf``. The file must exist on all cluster nodes, be readable by PostgreSQL and will be included from its location on the real ``postgresql.conf``. Note that Patroni will not monitor this file for changes, nor backup it. However, its settings can still be overriden by Patroni's own configuration facilities - see `dynamic configuration <https://github.com/zalando/patroni/blob/master/docs/dynamic_configuration.rst>`__ for details.
|
||||
- **parameters**: list of configuration settings for Postgres. Many of these are required for replication to work.
|
||||
- **pg\_ctl\_timeout**: How long should pg_ctl wait when doing ``start``, ``stop`` or ``restart``. Default value is 60 seconds.
|
||||
- **use\_pg\_rewind**: try to use pg\_rewind on the former leader when it joins cluster as a replica.
|
||||
- **remove\_data\_directory\_on\_rewind\_failure**: If this option is enabled, Patroni will remove postgres data directory and recreate replica. Otherwise it will try to follow the new leader. Default value is **false**.
|
||||
- **replica\_method** for each create_replica_method other than basebackup, you would add a configuration section of the same name. At a minimum, this should include "command" with a full path to the actual script to be executed. Other configuration parameters will be passed along to the script in the form "parameter=value".
|
||||
|
||||
REST API
|
||||
--------
|
||||
- **connect\_address**: IP address and port to access the REST API.
|
||||
- **listen**: IP address and port that Patroni will listen to, to provide health-check information for HAProxy.
|
||||
- **Optional**:
|
||||
- **authentication**:
|
||||
- **username**: Basic-auth username to protect unsafe REST API endpoints.
|
||||
- **password**: Basic-auth password to protect unsafe REST API endpoints.
|
||||
|
||||
- **certfile**: Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
|
||||
- **keyfile**: Specifies the file with the secret key in the PEM format.
|
||||
|
||||
ZooKeeper
|
||||
----------
|
||||
- **hosts**: list of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...'].
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
li {
|
||||
margin-bottom: 0.5em
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Patroni documentation build configuration file, created by
|
||||
# sphinx-quickstart on Mon Dec 19 16:54:09 2016.
|
||||
#
|
||||
# This file is execfile()d with the current directory set to its
|
||||
# containing dir.
|
||||
#
|
||||
# Note that not all possible configuration values are present in this
|
||||
# autogenerated file.
|
||||
#
|
||||
# All configuration values have a default; values that are commented out
|
||||
# serve to show the default.
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#
|
||||
import os
|
||||
# import sys
|
||||
# sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
#
|
||||
# needs_sphinx = '1.0'
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = ['sphinx.ext.intersphinx',
|
||||
'sphinx.ext.todo',
|
||||
'sphinx.ext.mathjax',
|
||||
'sphinx.ext.ifconfig',
|
||||
'sphinx.ext.viewcode']
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
# The suffix(es) of source filenames.
|
||||
# You can specify multiple suffix as a list of string:
|
||||
#
|
||||
# source_suffix = ['.rst', '.md']
|
||||
source_suffix = '.rst'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = 'Patroni'
|
||||
copyright = '2016, Zalando SE'
|
||||
author = 'Zalando SE'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '1.2'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '1.2.2'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This patterns also effect to html_static_path and html_extra_path
|
||||
exclude_patterns = []
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = True
|
||||
|
||||
|
||||
# -- Options for HTML output ----------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
|
||||
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
|
||||
if not on_rtd: # only import and set the theme if we're building docs locally
|
||||
import sphinx_rtd_theme
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
# documentation.
|
||||
#
|
||||
# html_theme_options = {}
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
|
||||
# -- Options for HTMLHelp output ------------------------------------------
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'Patronidoc'
|
||||
|
||||
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#
|
||||
# 'papersize': 'letterpaper',
|
||||
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#
|
||||
# 'pointsize': '10pt',
|
||||
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#
|
||||
# 'preamble': '',
|
||||
|
||||
# Latex figure (float) alignment
|
||||
#
|
||||
# 'figure_align': 'htbp',
|
||||
}
|
||||
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, 'Patroni.tex', 'Patroni Documentation',
|
||||
'Zalando SE', 'manual'),
|
||||
]
|
||||
|
||||
|
||||
# -- Options for manual page output ---------------------------------------
|
||||
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
(master_doc, 'patroni', 'Patroni Documentation',
|
||||
[author], 1)
|
||||
]
|
||||
|
||||
|
||||
# -- Options for Texinfo output -------------------------------------------
|
||||
|
||||
# Grouping the document tree into Texinfo files. List of tuples
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
(master_doc, 'Patroni', 'Patroni Documentation',
|
||||
author, 'Patroni', 'One line description of project.',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
# -- Options for Epub output ----------------------------------------------
|
||||
|
||||
# Bibliographic Dublin Core info.
|
||||
epub_title = project
|
||||
epub_author = author
|
||||
epub_publisher = author
|
||||
epub_copyright = copyright
|
||||
|
||||
# The unique identifier of the text. This can be a ISBN number
|
||||
# or the project homepage.
|
||||
#
|
||||
# epub_identifier = ''
|
||||
|
||||
# A unique identification for the text.
|
||||
#
|
||||
# epub_uid = ''
|
||||
|
||||
# A list of files that should not be packed into the epub file.
|
||||
epub_exclude_files = ['search.html']
|
||||
|
||||
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {'https://docs.python.org/': None}
|
||||
|
||||
# A possibility to have an own stylesheet, to add new rules or override existing ones
|
||||
# For the latter case, the CSS specificity of the rules should be higher than the default ones
|
||||
def setup(app):
|
||||
app.add_stylesheet("custom.css")
|
||||
@@ -0,0 +1,228 @@
|
||||
.. _dynamic_configuration:
|
||||
|
||||
Patroni configuration
|
||||
=====================
|
||||
|
||||
Patroni configuration is stored in the DCS (Distributed Configuration Store). There are 3 types of configuration:
|
||||
|
||||
- Dynamic configuration.
|
||||
These options can be set in DCS at any time. If the options changed are not part of the startup configuration,
|
||||
they are applied asynchronously (upon the next wake up cycle) to every node, which gets subsequently reloaded.
|
||||
If the node requires a restart to apply the configuration (for options with context postmaster, if their values
|
||||
have changed), a special flag, ``pending_restart`` indicating this, is set in the members.data JSON.
|
||||
Additionally, the node status also indicates this, by showing ``"restart_pending": true``.
|
||||
|
||||
- Local :ref:`configuration <settings>` (patroni.yml).
|
||||
These options are defined in the configuration file and take precedence over dynamic configuration.
|
||||
patroni.yml could be changed and reload in runtime (without restart of Patroni) by sending SIGHUP to the Patroni process or by performing ``POST /reload`` REST-API request.
|
||||
|
||||
- Environment :ref:`configuration <environment>` .
|
||||
It is possible to set/override some of the "Local" configuration parameters with environment variables.
|
||||
Environment configuration is very useful when you are running in a dynamic environment and you don't know some of the parameters in advance (for example it's not possible to know you external IP address when you are running inside ``docker``).
|
||||
|
||||
Some of the PostgreSQL parameters must hold the same values on the master and the replicas. For those, values set either in the local patroni configuration files or via the environment variables take no effect. To alter or set their values one must change the shared configuration in the DCS. Below is the actual list of such parameters together with the default values:
|
||||
|
||||
- max_connections: 100
|
||||
- max_locks_per_transaction: 64
|
||||
- max_worker_processes: 8
|
||||
- max_prepared_transactions: 0
|
||||
- wal_level: hot_standby
|
||||
- wal_log_hints: on
|
||||
- track_commit_timestamp: off
|
||||
|
||||
For the parameters below, PostgreSQL does not require equal values among the master and all the replicas. However, considering the possibility of a replica to become the master at any time, it doesn't really make sense to set them differently; therefore, Patroni restricts setting their values to the Dynamic configuration
|
||||
|
||||
- max_wal_senders: 5
|
||||
- max_replication_slots: 5
|
||||
- wal_keep_segments: 8
|
||||
|
||||
These parameters are validated to ensure they are sane, or meet a minimum value.
|
||||
|
||||
There are some other Postgres parameters controlled by Patroni:
|
||||
|
||||
- listen_addresses - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
|
||||
- port - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
|
||||
- cluster_name - is set either from ``scope`` or from ``PATRRONI_SCOPE`` environment variable
|
||||
- hot_standby: on
|
||||
|
||||
To be on the safe side parameters from the above lists are not written into ``postgresql.conf``, but passed as a list of arguments to the ``pg_ctl start`` which gives them the highest precedence, even above `ALTER SYSTEM <https://www.postgresql.org/docs/current/static/sql-altersystem.html>`__
|
||||
|
||||
|
||||
When applying the local or dynamic configuration options, the following actions are taken:
|
||||
|
||||
- The node first checks if there is a postgresql.base.conf or if the ``custom_conf`` parameter is set.
|
||||
- If the `custom_conf` parameter is set, it will take the file specified on it as a base configuration, ignoring `postgresql.base.conf` and `postgresql.conf`.
|
||||
- If the `custom_conf` parameter is not set and `postgresql.base.conf` exists, it contains the renamed "original" configuration and it will be used as a base configuration.
|
||||
- If there is no `custom_conf` nor `postgresql.base.conf`, the original postgresql.conf is taken and renamed to postgresql.base.conf.
|
||||
- The dynamic options (with the exceptions above) are dumped into the postgresql.conf and an include is set in
|
||||
postgresql.conf to the used base configuration (either postgresql.base.conf or what is on ``custom_conf``). Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present not.
|
||||
- Some parameters that are essential for Patroni to manage the cluster are overridden using the command line.
|
||||
- If some of the options that require restart are changed (we should look at the context in pg_settings and at the actual
|
||||
values of those options), a pending_restart flag of a given node is set. This flag is reset on any restart.
|
||||
|
||||
The parameters would be applied in the following order (run-time are given the highest priority):
|
||||
|
||||
1. load parameters from file `postgresql.base.conf` (or from a `custom_conf` file, if set)
|
||||
2. load parameters from file `postgresql.conf`
|
||||
3. load parameters from file `postgresql.auto.conf`
|
||||
4. run-time parameter using `-o --name=value`
|
||||
|
||||
This allows configuration for all the nodes (2), configuration for a specific node using `ALTER SYSTEM` (3) and ensures that parameters essential to the running of Patroni are enforced (4), as well as leaves room for configuration tools that manage `postgresql.conf` directly without involving Patroni (1).
|
||||
|
||||
|
||||
Also, the following Patroni configuration options can be changed only dynamically:
|
||||
|
||||
- ttl: 30
|
||||
- loop_wait: 10
|
||||
- retry_timeouts: 10
|
||||
- maximum_lag_on_failover: 1048576
|
||||
- postgresql.use_slots: true
|
||||
|
||||
Upon changing these options, Patroni will read the relevant section of the configuration stored in DCS and change its
|
||||
run-time values.
|
||||
|
||||
Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file ``patroni.dynamic.json`` located in the Postgres data directory. Only the master is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid.
|
||||
|
||||
REST API
|
||||
========
|
||||
|
||||
We provide a REST API endpoint for working with dynamic configuration.
|
||||
|
||||
GET /config
|
||||
-----------
|
||||
Get current version of dynamic configuration.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl -s localhost:8008/config | jq .
|
||||
{
|
||||
"ttl": 30,
|
||||
"loop_wait": 10,
|
||||
"retry_timeout": 10,
|
||||
"maximum_lag_on_failover": 1048576,
|
||||
"postgresql": {
|
||||
"use_slots": true,
|
||||
"use_pg_rewind": true,
|
||||
"parameters": {
|
||||
"hot_standby": "on",
|
||||
"wal_log_hints": "on",
|
||||
"wal_keep_segments": 8,
|
||||
"wal_level": "hot_standby",
|
||||
"max_wal_senders": 5,
|
||||
"max_replication_slots": 5,
|
||||
"max_connections": "100"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PATCH /config
|
||||
-------------
|
||||
Change existing configuration.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl -s -XPATCH -d \
|
||||
'{"loop_wait":5,"ttl":20,"postgresql":{"parameters":{"max_connections":"101"}}}' \
|
||||
http://localhost:8008/config | jq .
|
||||
{
|
||||
"ttl": 20,
|
||||
"loop_wait": 5,
|
||||
"maximum_lag_on_failover": 1048576,
|
||||
"retry_timeout": 10,
|
||||
"postgresql": {
|
||||
"use_slots": true,
|
||||
"use_pg_rewind": true,
|
||||
"parameters": {
|
||||
"hot_standby": "on",
|
||||
"wal_log_hints": "on",
|
||||
"wal_keep_segments": 8,
|
||||
"wal_level": "hot_standby",
|
||||
"max_wal_senders": 5,
|
||||
"max_replication_slots": 5,
|
||||
"max_connections": "101"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
The above REST API call patches the existing configuration and returns the new configuration.
|
||||
|
||||
Let's check that the node processed this configuration. First of all it should start printing log lines every 5 seconds (loop_wait=5). The change of "max_connections" requires a restart, so the "restart_pending" flag should be exposed:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl -s http://localhost:8008/patroni | jq .
|
||||
{
|
||||
"pending_restart": true,
|
||||
"database_system_identifier": "6287881213849985952",
|
||||
"postmaster_start_time": "2016-06-13 13:13:05.211 CEST",
|
||||
"xlog": {
|
||||
"location": 2197818976
|
||||
},
|
||||
"patroni": {
|
||||
"scope": "batman",
|
||||
"version": "1.0"
|
||||
},
|
||||
"state": "running",
|
||||
"role": "master",
|
||||
"server_version": 90503
|
||||
}
|
||||
|
||||
Removing parameters:
|
||||
|
||||
If you want to remove (reset) some setting just patch it with ``null``:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl -s -XPATCH -d \
|
||||
'{"postgresql":{"parameters":{"max_connections":null}}}' \
|
||||
http://localhost:8008/config | jq .
|
||||
{
|
||||
"ttl": 20,
|
||||
"loop_wait": 5,
|
||||
"retry_timeout": 10,
|
||||
"maximum_lag_on_failover": 1048576,
|
||||
"postgresql": {
|
||||
"use_slots": true,
|
||||
"use_pg_rewind": true,
|
||||
"parameters": {
|
||||
"hot_standby": "on",
|
||||
"unix_socket_directories": ".",
|
||||
"wal_keep_segments": 8,
|
||||
"wal_level": "hot_standby",
|
||||
"wal_log_hints": "on",
|
||||
"max_wal_senders": 5,
|
||||
"max_replication_slots": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Above call removes ``postgresql.parameters.max_connections`` from the dynamic configuration.
|
||||
|
||||
PUT /config
|
||||
-----------
|
||||
|
||||
It's also possible to perform the full rewrite of an existing dynamic configuration unconditionally:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ curl -s -XPUT -d \
|
||||
'{"maximum_lag_on_failover":1048576,"retry_timeout":10,"postgresql":{"use_slots":true,"use_pg_rewind":true,"parameters":{"hot_standby":"on","wal_log_hints":"on","wal_keep_segments":8,"wal_level":"hot_standby","unix_socket_directories":".","max_wal_senders":5}},"loop_wait":3,"ttl":20}' \
|
||||
http://localhost:8008/config | jq .
|
||||
{
|
||||
"ttl": 20,
|
||||
"maximum_lag_on_failover": 1048576,
|
||||
"retry_timeout": 10,
|
||||
"postgresql": {
|
||||
"use_slots": true,
|
||||
"parameters": {
|
||||
"hot_standby": "on",
|
||||
"unix_socket_directories": ".",
|
||||
"wal_keep_segments": 8,
|
||||
"wal_level": "hot_standby",
|
||||
"wal_log_hints": "on",
|
||||
"max_wal_senders": 5
|
||||
},
|
||||
"use_pg_rewind": true
|
||||
},
|
||||
"loop_wait": 3
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Graphviz source for ha_loop_diagram.png
|
||||
// recompile with:
|
||||
// dot -Tpng ha_loop_diagram.dot -o ha_loop_diagram.png
|
||||
|
||||
digraph G {
|
||||
rankdir=TB;
|
||||
fontname="sans-serif";
|
||||
penwidth="0.3";
|
||||
layout="dot";
|
||||
newrank=true;
|
||||
edge [fontname="sans-serif",
|
||||
fontsize=12,
|
||||
color=black,
|
||||
fontcolor=black];
|
||||
node [fontname=serif,
|
||||
fontsize=12,
|
||||
fillcolor=white,
|
||||
color=black,
|
||||
fontcolor=black,
|
||||
style=filled];
|
||||
"start" [label=Start, shape="rectangle", fillcolor="green"]
|
||||
"start" -> "load_cluster_from_dcs";
|
||||
"update_member" [label="Persist node state in DCS"]
|
||||
"update_member" -> "start"
|
||||
|
||||
subgraph cluster_run_cycle {
|
||||
label="run_cycle"
|
||||
"load_cluster_from_dcs" [label="Load cluster from DCS"];
|
||||
"touch_member" [label="Persist node in DCS"];
|
||||
"cluster.has_member" [shape="diamond", label="Is node registered on DCS?"]
|
||||
"cluster.has_member" -> "touch_member" [label="no" color="red"]
|
||||
"long_action_in_progress?" [shape="diamond" label="Is the PostgreSQL currently being\nstopping/starting/restarting/reinitializing?"]
|
||||
"load_cluster_from_dcs" -> "cluster.has_member";
|
||||
"touch_member" -> "long_action_in_progress?";
|
||||
"cluster.has_member" -> "long_action_in_progress?" [label="yes" color="green"];
|
||||
"long_action_in_progress?" -> "recovering?" [label="no" color="red"]
|
||||
"recovering?" [label="Was cluster recovering and failed?", shape="diamond"];
|
||||
"recovering?" -> "post_recover" [label="yes" color="green"];
|
||||
"recovering?" -> "data_directory_empty" [label="no" color="red"];
|
||||
"post_recover" [label="Remove leader key (if I was the leader)"];
|
||||
"data_directory_empty" [label="Is data folder empty?", shape="diamond"];
|
||||
"data_directory_empty" -> "cluster_initialize" [label="no" color="red"];
|
||||
"data_belongs_to_cluster" [label="Does data dir belong to cluster?", shape="diamond"];
|
||||
"data_belongs_to_cluster" -> "exit" [label="no" color="red"];
|
||||
"data_belongs_to_cluster" -> "is_healthy" [label="yes" color="green"]
|
||||
"exit" [label="Fail and exit", fillcolor=red];
|
||||
"cluster_initialize" [label="Is cluster initialized on DCS?" shape="diamond"]
|
||||
"cluster_initialize" -> "cluster.has_leader" [label="no" color="red"]
|
||||
"cluster.has_leader" [label="Does the cluster has leader?", shape="diamond"]
|
||||
"cluster.has_leader" -> "dcs.initialize" [label="no", color="red"]
|
||||
"cluster.has_leader" -> "is_healthy" [label="yes", color="green"]
|
||||
"cluster_initialize" -> "data_belongs_to_cluster" [label="yes" color="green"]
|
||||
"dcs.initialize" [label="Initialize new cluster"];
|
||||
"dcs.initialize" -> "is_healthy"
|
||||
"is_healthy" [label="Is node healthy?\n(running Postgres)", shape="diamond"];
|
||||
"recover" [label="Start as read-only\nand set Recover flag"]
|
||||
"is_healthy" -> "recover" [label="no" color="red"];
|
||||
"is_healthy" -> "cluster.is_unlocked" [label="yes" color="green"];
|
||||
"cluster.is_unlocked" [label="Does the cluster has a leader?", shape="diamond"]
|
||||
}
|
||||
|
||||
"post_recover" -> "update_member"
|
||||
"recover" -> "update_member"
|
||||
"long_action_in_progress?" -> "async_has_lock?" [label="yes" color="green"];
|
||||
"cluster.is_unlocked" -> "unhealthy_is_healthiest" [label="no" color="red"]
|
||||
"cluster.is_unlocked" -> "healthy_has_lock" [label="yes" color="green"]
|
||||
"data_directory_empty" -> "bootstrap.is_unlocked" [label="yes" color="green"]
|
||||
|
||||
subgraph cluster_async {
|
||||
label = "Long action in progress\n(Start/Stop/Restart/Reinitialize)"
|
||||
"async_has_lock?" [label="Do I have the leader lock?", shape="diamond"]
|
||||
"async_update_lock" [label="Renew leader lock"]
|
||||
"async_has_lock?" -> "async_update_lock" [label="yes" color="green"]
|
||||
}
|
||||
"async_update_lock" -> "update_member"
|
||||
"async_has_lock?" -> "update_member" [label="no" color="red"]
|
||||
|
||||
subgraph cluster_bootstrap {
|
||||
label = "Node bootstrap";
|
||||
"bootstrap.is_unlocked" [label="Does the cluster has a leader?", shape="diamond"]
|
||||
"bootstrap.is_initialized" [label="Does the cluster has an initialize key?", shape="diamond"]
|
||||
"bootstrap.is_unlocked" -> "bootstrap.is_initialized" [label="no" color="red"]
|
||||
"bootstrap.is_unlocked" -> "bootstrap.select_node" [label="yes" color="green"]
|
||||
"bootstrap.select_node" [label="Select a node to take a backup from"]
|
||||
"bootstrap.do_bootstrap" [label="Run pg_basebackup\n(async)"]
|
||||
"bootstrap.select_node" -> "bootstrap.do_bootstrap"
|
||||
"bootstrap.is_initialized" -> "bootstrap.initialization_race" [label="no" color="red"]
|
||||
"bootstrap.is_initialized" -> "bootstrap.wait_for_leader" [label="yes" color="green"]
|
||||
"bootstrap.initialization_race" [label="Race for initialize key"]
|
||||
"bootstrap.initialization_race" -> "bootstrap.won_initialize_race?"
|
||||
"bootstrap.won_initialize_race?" [label="Do I won initialize race?", shape="diamond"]
|
||||
"bootstrap.won_initialize_race?" -> "bootstrap.initdb_and_start" [label="yes" color="green"]
|
||||
"bootstrap.won_initialize_race?" -> "bootstrap.wait_for_leader" [label="no" color="red"]
|
||||
"bootstrap.wait_for_leader" [label="Need to wait for leader key"]
|
||||
"bootstrap.initdb_and_start" [label="Run initdb, start postgres and create roles"]
|
||||
"bootstrap.initdb_and_start" -> "bootstrap.success?"
|
||||
"bootstrap.success?" [label="Success", shape="diamond"]
|
||||
"bootstrap.success?" -> "bootstrap.take_leader_key" [label="yes" color="green"]
|
||||
"bootstrap.success?" -> "bootstrap.clean" [label="no" color="red"]
|
||||
"bootstrap.clean" [label="Remove initialize key from DCS\nand data directory from filesystem"]
|
||||
"bootstrap.take_leader_key" [label="Take a leader key in DCS"]
|
||||
}
|
||||
|
||||
"bootstrap.do_bootstrap" -> "update_member"
|
||||
"bootstrap.wait_for_leader" -> "update_member"
|
||||
"bootstrap.clean" -> "update_member"
|
||||
"bootstrap.take_leader_key" -> "update_member"
|
||||
|
||||
subgraph cluster_process_healthy_cluster {
|
||||
label = "process_healthy_cluster"
|
||||
"healthy_has_lock" [label="Am I the owner of the leader lock?", shape=diamond]
|
||||
"healthy_is_leader" [label="Is Postgres running as master?", shape=diamond]
|
||||
"healthy_no_lock" [label="Follow the leader (async,\ncreate/update recovery.conf and restart if necessary)"]
|
||||
"healthy_has_lock" -> "healthy_no_lock" [label="no" color="red"]
|
||||
"healthy_has_lock" -> "healthy_update_leader_lock" [label="yes" color="green"]
|
||||
"healthy_update_leader_lock" [label="Try to update leader lock"]
|
||||
"healthy_update_leader_lock" -> "healthy_update_success"
|
||||
"healthy_update_success" [label="Success?", shape=diamond]
|
||||
"healthy_update_success" -> "healthy_is_leader" [label="yes" color="green"]
|
||||
"healthy_update_success" -> "healthy_demote" [label="no" color="red"]
|
||||
"healthy_demote" [label="Demote (async,\nrestart in read-only)"]
|
||||
"healthy_failover" [label="Promote Postgres to master"]
|
||||
"healthy_is_leader" -> "healthy_failover" [label="no" color="red"]
|
||||
}
|
||||
"healthy_demote" -> "update_member"
|
||||
"healthy_is_leader" -> "update_member" [label="yes" color="green"]
|
||||
"healthy_failover" -> "update_member"
|
||||
"healthy_no_lock" -> "update_member"
|
||||
|
||||
subgraph cluster_process_unhealthy_cluster {
|
||||
label = "process_unhealthy_cluster"
|
||||
"unhealthy_is_healthiest" [label="Am I the healthiest node?", shape="diamond"]
|
||||
"unhealthy_is_healthiest" -> "unhealthy_leader_race" [label="yes", color="green"]
|
||||
"unhealthy_leader_race" [label="Try to create leader key"]
|
||||
"unhealthy_leader_race" -> "unhealthy_acquire_lock"
|
||||
"unhealthy_acquire_lock" [label="Was I able to get the lock?", shape="diamond"]
|
||||
"unhealthy_is_leader" [label="Is Postgres running as master?", shape=diamond]
|
||||
"unhealthy_acquire_lock" -> "unhealthy_is_leader" [label="yes" color="green"]
|
||||
"unhealthy_is_leader" -> "unhealthy_promote" [label="no" color="red"]
|
||||
"unhealthy_promote" [label="Promote to master"]
|
||||
"unhealthy_is_healthiest" -> "unhealthy_follow" [label="no" color="red"]
|
||||
"unhealthy_follow" [label="try to follow somebody else()"]
|
||||
"unhealthy_acquire_lock" -> "unhealthy_follow" [label="no" color="red"]
|
||||
}
|
||||
"unhealthy_follow" -> "update_member"
|
||||
"unhealthy_promote" -> "update_member"
|
||||
"unhealthy_is_leader" -> "update_member" [label="yes" color="green"]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 507 KiB |
@@ -0,0 +1,36 @@
|
||||
.. Patroni documentation master file, created by
|
||||
sphinx-quickstart on Mon Dec 19 16:54:09 2016.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
Introduction
|
||||
============
|
||||
|
||||
Patroni is a template for you to create your own customized, high-availability solution using Python and - for maximum accessibility - a distributed configuration store like `ZooKeeper <https://zookeeper.apache.org/>`__, `etcd <https://github.com/coreos/etcd>`__ or `Consul <https://github.com/hashicorp/consul>`__. Database engineers, DBAs, DevOps engineers, and SREs who are looking to quickly deploy HA PostgreSQL in the datacenter-or anywhere else-will hopefully find it useful.
|
||||
|
||||
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely. There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__.
|
||||
|
||||
**Note to Kubernetes users**: We're currently developing Patroni to be as useful as possible for teams running Kubernetes on top of Google Compute Engine; Patroni can be the HA solution for Postgres in such an environment. To this end, we've created a `Helm Chart <https://github.com/kubernetes/charts/tree/master/incubator/patroni>`__ that enables you to deploy a five-node Patroni cluster using a Kubernetes PetSet.
|
||||
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
README
|
||||
dynamic_configuration
|
||||
ENVIRONMENT
|
||||
SETTINGS
|
||||
replication_modes
|
||||
pause
|
||||
releases
|
||||
CONTRIBUTING
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
.. _pause:
|
||||
|
||||
Pause/Resume mode for the cluster
|
||||
=================================
|
||||
|
||||
The goal
|
||||
--------
|
||||
|
||||
Under certain circumstances Patroni needs to temporary step down from managing the cluster, while still retaining the cluster state in DCS. Possible use cases are uncommon activities on the cluster, such as major version upgrades or corruption recovery. During those activities nodes are often started and stopped for the reason unknown to Patroni, some nodes can be even temporary promoted, violating the assumption of running only one master. Therefore, Patroni needs to be able to "detach" from the running cluster, implementing an equivalent of the maintenance mode in Pacemaker.
|
||||
|
||||
|
||||
|
||||
The implementation
|
||||
------------------
|
||||
|
||||
When Patroni runs in a paused mode, it does not change the state of PostgreSQL, except for the following cases:
|
||||
|
||||
- For each node, the member key in DCS is updated with the current information about the cluster. This causes Patroni to run read-only queries on a member node if the member is running.
|
||||
|
||||
- For the Postgres master with the leader lock Patroni updates the lock. If the node with the leader lock stops being the master (i.e. is demoted manually), Patroni will release the lock instead of promoting the node back.
|
||||
|
||||
- Manual unscheduled restart, reinitialize and manual failover are allowed. Manual failover is only allowed if the node to failover to is specified. In the paused mode, manual failover does not require a running master node.
|
||||
|
||||
- If 'parallel' masters are detected by Patroni, it emits a warning, but does not demote the masters without the leader lock.
|
||||
|
||||
- If there is no leader lock in the cluster, the running master acquires the lock. If there is more than one master node, then the first master to acquire the lock wins. If there are no masters altogether, Patroni does not try to promote any replicas. There is an exception in this rule: if there is no leader lock because the old master has demoted itself due to the manual promotion, then only the candidate node mentioned in the promotion request may take the leader lock. When the new leader lock is granted (i.e. after promoting a replica manually), Patroni makes sure the replicas that were streaming from the previous leader will switch to the new one.
|
||||
|
||||
- When Postgres is stopped, Patroni does not try to start it. When Patroni is stopped, it does not to stop Postgres instance it is managing.
|
||||
|
||||
User guide
|
||||
----------
|
||||
|
||||
``patronictl`` supports ``pause`` and ``resume`` commands.
|
||||
|
||||
One can also issue a ``PATCH`` request to the ``{namespace}/{cluster}/config`` key with ``{"pause": true/false/null}``
|
||||
@@ -0,0 +1,394 @@
|
||||
.. _releases:
|
||||
|
||||
Release notes
|
||||
=============
|
||||
|
||||
Version 1.2
|
||||
-----------
|
||||
|
||||
This version introduces significant improvements over the handling of synchronous replication, makes the startup process and failover more reliable, adds PostgreSQL 9.6 support and fixes plenty of bugs.
|
||||
In addition, the documentation, including these release notes, has been moved to https://patroni.readthedocs.io.
|
||||
|
||||
**Synchronous replication**
|
||||
|
||||
- Add synchronous replication support. (Ants Aasma)
|
||||
|
||||
Adds a new configuration variable ``synchronous_mode``. When enabled, Patroni will manage ``synchronous_standby_names`` to enable synchronous replication whenever there are healthy standbys available. When synchronous mode is enabled, Patroni will automatically fail over only to a standby that was synchronously replicating at the time of the master failure. This effectively means that no user visible transaction gets lost in such a case. See the
|
||||
:ref:`feature documentation <synchronous_mode>` for the detailed description and implementation details.
|
||||
|
||||
**Reliability improvements**
|
||||
|
||||
- Do not try to update the leader position stored in the ``leader optime`` key when PostgreSQL is not 100% healthy. Demote immediately when the update of the leader key failed. (Alexander Kukushkin)
|
||||
|
||||
- Exclude unhealthy nodes from the list of targets to clone the new replica from. (Alexander)
|
||||
|
||||
- Implement retry and timeout strategy for Consul similar to how it is done for Etcd. (Alexander)
|
||||
|
||||
- Make ``--dcs`` and ``--config-file`` apply to all options in ``patronictl``. (Alexander)
|
||||
|
||||
- Write all postgres parameters into postgresql.conf. (Alexander)
|
||||
|
||||
It allows starting PostgreSQL configured by Patroni with just ``pg_ctl``.
|
||||
|
||||
- Avoid exceptions when there are no users in the config. (Kirill Pushkin)
|
||||
|
||||
- Allow pausing an unhealthy cluster. Before this fix, ``patronictl`` would bail out if the node it tries to execute pause on is unhealthy. (Alexander)
|
||||
|
||||
- Improve the leader watch functionality. (Alexander)
|
||||
|
||||
Previously the replicas were always watching the leader key (sleeping until the timeout or the leader key changes). With this change, they only watch
|
||||
when the replica's PostgreSQL is in the ``running`` state and not when it is stopped/starting or restarting PostgreSQL.
|
||||
|
||||
- Avoid running into race conditions when handling SIGCHILD as a PID 1. (Alexander)
|
||||
|
||||
Previously a race condition could occur when running inside the Docker containers, since the same process inside Patroni both spawned new processes
|
||||
and handled SIGCHILD from them. This change uses fork/execs for Patroni and leaves the original PID 1 process responsible for handling signals from children.
|
||||
|
||||
- Fix WAL-E restore. (Oleksii Kliukin)
|
||||
|
||||
Previously WAL-E restore used the ``no_master`` flag to avoid consulting with the master altogether, making Patroni always choose restoring
|
||||
from WAL over the ``pg_basebackup``. This change reverts it to the original meaning of ``no_master``, namely Patroni WAL-E restore may be selected as a replication method if the master is not running.
|
||||
The latter is checked by examining the connection string passed to the method. In addition, it makes the retry mechanism more robust and handles other minutia.
|
||||
|
||||
- Implement asynchronous DNS resolver cache. (Alexander)
|
||||
|
||||
Avoid failing when DNS is temporary unavailable (for instance, due to an excessive traffic received by the node).
|
||||
|
||||
- Implement starting state and master start timeout. (Ants, Alexander)
|
||||
|
||||
Previously ``pg_ctl`` waited for a timeout and then happily trodded on considering PostgreSQL to be running. This caused PostgreSQL to show up in listings as running when it was actually not and caused a race condition that resulted in either a failover, or a crash recovery, or a crash recovery interrupted by failover and a missed rewind.
|
||||
This change adds a ``master_start_timeout`` parameter and introduces a new state for the main HA loop: ``starting``. When ``master_start_timeout`` is 0 we will failover immediately when the master crashes as soon as there is a failover candidate. Otherwise, Patroni will wait after attempting to start PostgreSQL on the master for the duration of the timeout; when it expires, it will failover if possible. Manual failover requests will be honored during the crash of the master even before the timeout expiration.
|
||||
|
||||
Introduce the ``timeout`` parameter to the ``restart`` API endpoint and ``patronictl``. When it is set and restart takes longer than the timeout, PostgreSQL is considered unhealthy and the other nodes becomes eligible to take the leader lock.
|
||||
|
||||
- Fix ``pg_rewind`` behavior in a pause mode. (Ants)
|
||||
|
||||
Avoid unnecessary restart in a pause mode when Patroni thinks it needs to rewind but rewind is not possible (i.e. ``pg_rewind`` is not present). Fallback to default ``libpq`` values for the ``superuser`` (default OS user) if ``superuser`` authentication is missing from the ``pg_rewind`` related Patroni configuration section.
|
||||
|
||||
- Serialize callback execution. Kill the previous callback of the same type when the new one is about to run. Fix the issue of spawning zombie processes when running callbacks. (Alexander)
|
||||
|
||||
- Avoid promoting a former master when the leader key is set in DCS but update to this leader key fails. (Alexander)
|
||||
|
||||
This avoids the issue of a current master continuing to keep its role when it is partitioned together with the minority of nodes in Etcd and other DCSs that allow "inconsistent reads".
|
||||
|
||||
**Miscellaneous**
|
||||
|
||||
- Add ``post_init`` configuration option on bootstrap. (Alejandro Martínez)
|
||||
|
||||
Patroni will call the script argument of this option right after running ``initdb`` and starting up PostgreSQL for a new cluster. The script receives a connection URL with ``superuser``
|
||||
and sets ``PGPASSFILE`` to point to the ``.pgpass`` file containing the password. If the script fails, Patroni initialization fails as well. It is useful for adding
|
||||
new users or creating extensions in the new cluster.
|
||||
|
||||
- Implement PostgreSQL 9.6 support. (Alexander)
|
||||
|
||||
Use ``wal_level = replica`` as a synonym for ``hot_standby``, avoiding pending_restart flag when it changes from one to another. (Alexander)
|
||||
|
||||
**Documentation improvements**
|
||||
|
||||
- Add a Patroni main `loop workflow diagram <https://raw.githubusercontent.com/zalando/patroni/master/docs/ha_loop_diagram.png>`__. (Alejandro, Alexander)
|
||||
|
||||
- Improve README, adding the Helm chart and links to release notes. (Lauri Apple)
|
||||
|
||||
- Move Patroni documentation to ``Read the Docs``. The up-to-date documentation is available at https://patroni.readthedocs.io. (Oleksii)
|
||||
|
||||
Makes the documentation easily viewable from different devices (including smartphones) and searchable.
|
||||
|
||||
- Move the package to the semantic versioning. (Oleksii)
|
||||
|
||||
Patroni will follow the major.minor.patch version schema to avoid releasing the new minor version on small but critical bugfixes. We will only publish the release notes for the minor version, which will include all patches.
|
||||
|
||||
|
||||
Version 1.1
|
||||
-----------
|
||||
|
||||
This release improves management of Patroni cluster by bring in pause mode, improves maintenance with scheduled and conditional restarts, makes Patroni interaction with Etcd or Zookeeper more resilient and greatly enhances patronictl.
|
||||
|
||||
**Upgrade notice**
|
||||
|
||||
When upgrading from releases below 1.0 read about changing of credentials and configuration format at 1.0 release notes.
|
||||
|
||||
**Pause mode**
|
||||
|
||||
- Introduce pause mode to temporary detach Patroni from managing PostgreSQL instance (Murat Kabilov, Alexander Kukushkin, Oleksii Kliukin).
|
||||
|
||||
Previously, one had to send SIGKILL signal to Patroni to stop it without terminating PostgreSQL. The new pause mode detaches Patroni from PostgreSQL cluster-wide without terminating Patroni. It is similar to the maintenance mode in Pacemaker. Patroni is still responsible for updating member and leader keys in DCS, but it will not start, stop or restart PostgreSQL server in the process. There are a few exceptions, for instance, manual failovers, reinitializes and restarts are still allowed. You can read :ref:`a detailed description of this feature <pause>`.
|
||||
|
||||
In addition, patronictl supports new ``pause`` and ``resume`` commands to toggle the pause mode.
|
||||
|
||||
**Scheduled and conditional restarts**
|
||||
|
||||
- Add conditions to the restart API command (Oleksii)
|
||||
|
||||
This change enhances Patroni restarts by adding a couple of conditions that can be verified in order to do the restart. Among the conditions are restarting when PostgreSQL role is either a master or a replica, checking the PostgreSQL version number or restarting only when restart is necessary in order to apply configuration changes.
|
||||
|
||||
- Add scheduled restarts (Oleksii)
|
||||
|
||||
It is now possible to schedule a restart in the future. Only one scheduled restart per node is supported. It is possible to clear the scheduled restart if it is not needed anymore. A combination of scheduled and conditional restarts is supported, making it possible, for instance, to scheduled minor PostgreSQL upgrades in the night, restarting only the instances that are running the outdated minor version without adding postgres-specific logic to administration scripts.
|
||||
|
||||
- Add support for conditional and scheduled restarts to patronictl (Murat).
|
||||
|
||||
patronictl restart supports several new options. There is also patronictl flush command to clean the scheduled actions.
|
||||
|
||||
**Robust DCS interaction**
|
||||
|
||||
- Set Kazoo timeouts depending on the loop_wait (Alexander)
|
||||
|
||||
Originally, ping_timeout and connect_timeout values were calculated from the negotiated session timeout. Patroni loop_wait was not taken into account. As
|
||||
a result, a single retry could take more time than the session timeout, forcing Patroni to release the lock and demote.
|
||||
|
||||
This change set ping and connect timeout to half of the value of loop_wait, speeding up detection of connection issues and leaving enough time to retry the connection attempt before loosing the lock.
|
||||
|
||||
- Update Etcd topology only after original request succeed (Alexander)
|
||||
|
||||
Postpone updating the Etcd topology known to the client until after the original request. When retrieving the cluster topology, implement the retry timeouts depending on the known number of nodes in the Etcd cluster. This makes our client prefer to get the results of the request to having the up-to-date list of nodes.
|
||||
|
||||
Both changes make Patroni connections to DCS more robust in the face of network issues.
|
||||
|
||||
**Patronictl, monitoring and configuration**
|
||||
|
||||
- Return information about streaming replicas via the API (Feike Steenbergen)
|
||||
|
||||
Previously, there was no reliable way to query Patroni about PostgreSQL instances that fail to stream changes (for instance, due to connection issues). This change exposes the contents of pg_stat_replication via the /patroni endpoint.
|
||||
|
||||
- Add patronictl scaffold command (Oleksii)
|
||||
|
||||
Add a command to create cluster structure in Etcd. The cluster is created with user-specified sysid and leader, and both leader and member keys are made persistent. This command is useful to create so-called master-less configurations, where Patroni cluster consisting of only replicas replicate from the external master node that is unaware of Patroni. Subsequently, one
|
||||
may remove the leader key, promoting one of the Patroni nodes and replacing
|
||||
the original master with the Patroni-based HA cluster.
|
||||
|
||||
- Add configuration option ``bin_dir`` to locate PostgreSQL binaries (Ants Aasma)
|
||||
|
||||
It is useful to be able to specify the location of PostgreSQL binaries explicitly when Linux distros that support installing multiple PostgreSQL versions at the same time.
|
||||
|
||||
- Allow configuration file path to be overridden using ``custom_conf`` of (Alejandro Martínez)
|
||||
|
||||
Allows for custom configuration file paths, which will be unmanaged by Patroni, :ref:`details <postgresql_settings>`.
|
||||
|
||||
**Bug fixes and code improvements**
|
||||
|
||||
- Make Patroni compatible with new version schema in PostgreSQL 10 and above (Feike)
|
||||
|
||||
Make sure that Patroni understand 2-digits version numbers when doing conditional restarts based on the PostgreSQL version.
|
||||
|
||||
- Use pkgutil to find DCS modules (Alexander)
|
||||
|
||||
Use the dedicated python module instead of traversing directories manually in order to find DCS modules.
|
||||
|
||||
- Always call on_start callback when starting Patroni (Alexander)
|
||||
|
||||
Previously, Patroni did not call any callbacks when attaching to the already running node with the correct role. Since callbacks are often used to route
|
||||
client connections that could result in the failure to register the running
|
||||
node in the connection routing scheme. With this fix, Patroni calls on_start
|
||||
callback even when attaching to the already running node.
|
||||
|
||||
- Do not drop active replication slots (Murat, Oleksii)
|
||||
|
||||
Avoid dropping active physical replication slots on master. PostgreSQL cannot
|
||||
drop such slots anyway. This change makes possible to run non-Patroni managed
|
||||
replicas/consumers on the master.
|
||||
|
||||
- Close Patroni connections during start of the PostgreSQL instance (Alexander)
|
||||
|
||||
Forces Patroni to close all former connections when PostgreSQL node is started. Avoids the trap of reusing former connections if postmaster was killed with SIGKILL.
|
||||
|
||||
- Replace invalid characters when constructing slot names from member names (Ants)
|
||||
|
||||
Make sure that standby names that do not comply with the slot naming rules don't cause the slot creation and standby startup to fail. Replace the dashes in the slot names with underscores and all other characters not allowed in slot names with their unicode codepoints.
|
||||
|
||||
Version 1.0
|
||||
-----------
|
||||
|
||||
This release introduces the global dynamic configuration that allows dynamic changes of the PostgreSQL and Patroni configuration parameters for the entire HA cluster. It also delivers numerous bugfixes.
|
||||
|
||||
**Upgrade notice**
|
||||
|
||||
When upgrading from v0.90 or below, always upgrade all replicas before the master. Since we don't store replication credentials in DCS anymore, an old replica won't be able to connect to the new master.
|
||||
|
||||
**Dynamic Configuration**
|
||||
|
||||
- Implement the dynamic global configuration (Alexander Kukushkin)
|
||||
|
||||
Introduce new REST API endpoint /config to provide PostgreSQL and Patroni configuration parameters that should be set globally for the entire HA cluster (master and all the replicas). Those parameters are set in DCS and in many cases can be applied without disrupting PostgreSQL or Patroni. Patroni sets a special flag called "pending restart" visible via the API when some of the values require the PostgreSQL restart. In that case, restart should be issued manually via the API.
|
||||
|
||||
Patroni SIGHUP or POST to /reload will make it re-read the configuration file.
|
||||
|
||||
See the :ref:`dynamic configuration <dynamic_configuration>` for the details on which parameters can be changed and the order of processing difference configuration sources.
|
||||
|
||||
The configuration file format *has changed* since the v0.90. Patroni is still compatible with the old configuration files, but in order to take advantage of the bootstrap parameters one needs to change it. Users are encourage to update them by referring to the :ref:`dynamic configuraton documentation page <dynamic_configuration>`.
|
||||
|
||||
**More flexible configuration***
|
||||
|
||||
- Make postgresql configuration and database name Patroni connects to configurable (Misja Hoebe)
|
||||
|
||||
Introduce `database` and `config_base_name` configuration parameters. Among others, it makes possible to run Patroni with PipelineDB and other PostgreSQL forks.
|
||||
|
||||
- Implement possibility to configure some Patroni configuration parameters via environment (Alexander)
|
||||
|
||||
Those include the scope, the node name and the namespace, as well as the secrets and makes it easier to run Patroni in a dynamic environment, i.e. Kubernetes Please, refer to the :ref:`supported environment variables <environment>` for further details.
|
||||
|
||||
- Update the built-in Patroni docker container to take advantage of environment-based configuration (Feike Steenbergen).
|
||||
|
||||
- Add Zookeeper support to Patroni docker image (Alexander)
|
||||
|
||||
- Split the Zookeeper and Exhibitor configuration options (Alexander)
|
||||
|
||||
- Make patronictl reuse the code from Patroni to read configuration (Alexander)
|
||||
|
||||
This allows patronictl to take advantage of environment-based configuration.
|
||||
|
||||
- Set application name to node name in primary_conninfo (Alexander)
|
||||
|
||||
This simplifies identification and configuration of synchronous replication for a given node.
|
||||
|
||||
**Stability, security and usability improvements**
|
||||
|
||||
- Reset sysid and do not call pg_controldata when restore of backup in progress (Alexander)
|
||||
|
||||
This change reduces the amount of noise generated by Patroni API health checks during the lengthy initialization of this node from the backup.
|
||||
|
||||
- Fix a bunch of pg_rewind corner-cases (Alexander)
|
||||
|
||||
Avoid running pg_rewind if the source cluster is not the master.
|
||||
|
||||
In addition, avoid removing the data directory on an unsuccessful rewind, unless the new parameter *remove_data_directory_on_rewind_failure* is set to true. By default it is false.
|
||||
|
||||
- Remove passwords from the replication connection string in DCS (Alexander)
|
||||
|
||||
Previously, Patroni always used the replication credentials from the Postgres URL in DCS. That is now changed to take the credentials from the patroni configuration. The secrets (replication username and password) and no longer exposed in DCS.
|
||||
|
||||
- Fix the asynchronous machinery around the demote call (Alexander)
|
||||
|
||||
Demote now runs totally asynchronously without blocking the DCS interactions.
|
||||
|
||||
- Make patronictl always send the authorization header if it is configured (Alexander)
|
||||
|
||||
This allows patronictl to issue "protected" requests, i.e. restart or reinitialize, when Patroni is configured to require authorization on those.
|
||||
|
||||
- Handle the SystemExit exception correctly (Alexander)
|
||||
|
||||
Avoids the issues of Patroni not stopping properly when receiving the SIGTERM
|
||||
|
||||
- Sample haproxy templates for confd (Alexander)
|
||||
|
||||
Generates and dynamically changes haproxy configuration from the patroni state in the DCS using confide
|
||||
|
||||
- Improve and restructure the documentation to make it more friendly to the new users (Lauri Apple)
|
||||
|
||||
- API must report role=master during pg_ctl stop (Alexander)
|
||||
|
||||
Makes the callback calls more reliable, particularly in the cluster stop case. In addition, introduce the `pg_ctl_timeout` option to set the timeout for the start, stop and restart calls via the `pg_ctl`.
|
||||
|
||||
- Fix the retry logic in etcd (Alexander)
|
||||
|
||||
Make retries more predictable and robust.
|
||||
|
||||
- Make Zookeeper code more resilient against short network hiccups (Alexander)
|
||||
|
||||
Reduce the connection timeouts to make Zookeeper connection attempts more frequent.
|
||||
|
||||
Version 0.90
|
||||
------------
|
||||
|
||||
This releases adds support for Consul, includes a new *noloadbalance* tag, changes the behavior of the *clonefrom* tag, improves *pg_rewind* handling and improves *patronictl* control program.
|
||||
|
||||
**Consul support**
|
||||
|
||||
- Implement Consul support (Alexander Kukushkin)
|
||||
|
||||
Patroni runs against Consul, in addition to Etcd and Zookeeper. the connection parameters can be configured in the YAML file.
|
||||
|
||||
**New and improved tags**
|
||||
|
||||
- Implement *noloadbalance* tag (Alexander)
|
||||
|
||||
This tag makes Patroni always return that the replica is not available to the load balancer.
|
||||
|
||||
- Change the implementation of the *clonefrom* tag (Alexander)
|
||||
|
||||
Previously, a node name had to be supplied to the *clonefrom*, forcing a tagged replica to clone from the specific node. The new implementation makes *clonefrom* a boolean tag: if it is set to true, the replica becomes a candidate for other replicas to clone from it. When multiple candidates are present, the replicas picks one randomly.
|
||||
|
||||
**Stability and security improvements**
|
||||
|
||||
- Numerous reliability improvements (Alexander)
|
||||
|
||||
Removes some spurious error messages, improves the stability of the failover, addresses some corner cases with reading data from DCS, shutdown, demote and reattaching of the former leader.
|
||||
|
||||
- Improve systems script to avoid killing Patroni children on stop (Jan Keirse, Alexander Kukushkin)
|
||||
|
||||
Previously, when stopping Patroni, *systemd* also sent a signal to PostgreSQL. Since Patroni also tried to stop PostgreSQL by itself, it resulted in sending to different shutdown requests (the smart shutdown, followed by the fast shutdown). That resulted in replicas disconnecting too early and a former master not being able to rejoin after demote. Fix by Jan with prior research by Alexander.
|
||||
|
||||
- Eliminate some cases where the former master was unable to call pg_rewind before rejoining as a replica (Oleksii Kliukin)
|
||||
|
||||
Previously, we only called *pg_rewind* if the former master had crashed. Change this to always run pg_rewind for the former master as long as pg_rewind is present in the system. This fixes the case when the master is shut down before the replicas managed to get the latest changes (i.e. during the "smart" shutdown).
|
||||
|
||||
- Numerous improvements to unit- and acceptance- tests, in particular, enable support for Zookeeper and Consul (Alexander).
|
||||
|
||||
- Make Travis CI faster and implement support for running tests against Zookeeper (Exhibitor) and Consul (Alexander)
|
||||
|
||||
Both unit and acceptance tests run automatically against Etcd, Zookeeper and Consul on each commit or pull-request.
|
||||
|
||||
- Clear environment variables before calling PostgreSQL commands from Patroni (Feike Steenbergen)
|
||||
|
||||
This prevents a possibility of reading system environment variables by connecting to the PostgreSQL cluster managed by Patroni.
|
||||
|
||||
**Configuration and control changes**
|
||||
|
||||
- Unify patronictl and Patroni configuration (Feike)
|
||||
|
||||
patronictl can use the same configuration file as Patroni itself.
|
||||
|
||||
- Enable Patroni to read the configuration from the environment variables (Oleksii)
|
||||
|
||||
This simplifies generating configuration for Patroni automatically, or merging a single configuration from different sources.
|
||||
|
||||
- Include database system identifier in the information returned by the API (Feike)
|
||||
|
||||
- Implement *delete_cluster* for all available DCSs (Alexander)
|
||||
|
||||
Enables support for DCSs other than Etcd in patronictl.
|
||||
|
||||
|
||||
Version 0.80
|
||||
------------
|
||||
|
||||
This release adds support for *cascading replication* and simplifies Patroni management by providing *scheduled failovers*. One may use older versions of Patroni (in particular, 0.78) combined with this one in order to migrate to the new release. Note that the scheduled failover and cascading replication related features will only work with Patroni 0.80 and above.
|
||||
|
||||
**Cascading replication**
|
||||
|
||||
- Add support for the *replicatefrom* and *clonefrom* tags for the patroni node (Oleksii Kliukin).
|
||||
|
||||
The tag *replicatefrom* allows a replica to use an arbitrary node a source, not necessary the master. The *clonefrom* does the same for the initial backup. Together, they enable Patroni to fully support cascading replication.
|
||||
|
||||
- Add support for running replication methods to initialize the replica even without a running replication connection (Oleksii).
|
||||
|
||||
This is useful in order to create replicas from the snapshots stored on S3 or FTP. A replication method that does not require a running replication connection should supply *no_master: true* in the yaml configuration. Those scripts will still be called in order if the replication connection is present.
|
||||
|
||||
**Patronictl, API and DCS improvements**
|
||||
|
||||
- Implement scheduled failovers (Feike Steenbergen).
|
||||
|
||||
Failovers can be scheduled to happen at a certain time in the future, using either patronictl, or API calls.
|
||||
|
||||
- Add support for *dbuser* and *password* parameters in patronictl (Feike).
|
||||
|
||||
- Add PostgreSQL version to the health check output (Feike).
|
||||
|
||||
- Improve Zookeeper support in patronictl (Oleksandr Shulgin)
|
||||
|
||||
- Migrate to python-etcd 0.43 (Alexander Kukushkin)
|
||||
|
||||
**Configuration**
|
||||
|
||||
- Add a sample systems configuration script for Patroni (Jan Keirse).
|
||||
|
||||
- Fix the problem of Patroni ignoring the superuser name specified in the configuration file for DB connections (Alexander).
|
||||
|
||||
- Fix the handling of CTRL-C by creating a separate session ID and process group for the postmaster launched by Patroni (Alexander).
|
||||
|
||||
**Tests**
|
||||
|
||||
- Add acceptance tests with *behave* in order to check real-world scenarios of running Patroni (Alexander, Oleksii).
|
||||
|
||||
The tests can be launched manually using the *behave* command. They are also launched automatically for pull requests and after commits.
|
||||
|
||||
Releases notes for some older versions can be found on `project's github page <https://github.com/zalando/patroni/releases>`__.
|
||||
@@ -0,0 +1,68 @@
|
||||
.. _replication_modes:
|
||||
|
||||
=================
|
||||
Replication modes
|
||||
=================
|
||||
|
||||
Patroni uses PostgreSQL streaming replication. For more information about streaming replication, see the `Postgres documentation <http://www.postgresql.org/docs/current/static/warm-standby.html#STREAMING-REPLICATION>`__. By default Patroni configures PostgreSQL for asynchronous replication. Choosing your replication schema is dependent on your business considerations. Investigate both async and sync replication, as well as other HA solutions, to determine which solution is best for you.
|
||||
|
||||
Asynchronous mode durability
|
||||
----------------------------
|
||||
|
||||
In asynchronous mode the cluster is allowed to lose some committed transactions to ensure availability. When master server fails or becomes unavailable for any other reason Patroni will automatically promote a sufficiently healthy standby to master. Any transactions that have not been replicated to that standby remain in a "forked timeline" on the master, and are effectively unrecoverable [1]_.
|
||||
|
||||
The amount of transactions that can be lost is controlled via ``maximum_lag_on_failover`` parameter. Because master transaction log position is not sampled in real time, in reality the amount of lost data on failover is worst case bounded by ``maximum_lag_on_failover`` bytes of transaction log plus the amount that is written in the last ``ttl`` seconds (``loop_wait``/2 seconds in the average case). However typical steady state replication delay is well under a second.
|
||||
|
||||
PostgreSQL synchronous replication
|
||||
----------------------------------
|
||||
|
||||
You can use Postgres's `synchronous replication <http://www.postgresql.org/docs/current/static/warm-standby.html#SYNCHRONOUS-REPLICATION>`__ with Patroni. Synchronous replication ensures consistency across a cluster by confirming that writes are written to a secondary before returning to the connecting client with a success. The cost of synchronous replication: reduced throughput on writes. This throughput will be entirely based on network performance.
|
||||
|
||||
In hosted datacenter environments (like AWS, Rackspace, or any network you do not control), synchronous replication significantly increases the variability of write performance. If followers become inaccessible from the leader, the leader effectively becomes read-only.
|
||||
|
||||
To enable a simple synchronous replication test, add the follow lines to the ``parameters`` section of your YAML configuration files:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
synchronous_commit: "on"
|
||||
synchronous_standby_names: "*"
|
||||
|
||||
When using PostgreSQL synchronous replication, use at least three Postgres data nodes to ensure write availability if one host fails.
|
||||
|
||||
Using PostgreSQL synchronous replication does not guarantee zero lost transactions under all circumstances. When master and standby that is currently acting as synchronous fail simultaneously a third node that might not contain all transactions will be promoted.
|
||||
|
||||
.. _synchronous_mode:
|
||||
|
||||
Synchronous mode
|
||||
----------------
|
||||
|
||||
For use cases where losing committed transactions is not permissible you can turn on Patronis ``synchronous_mode``. When ``synchronous_mode`` is turned on Patroni will not promote a standby unless it is certain that the standby contains all transactions that may have returned a successful commit status to client [2]_. This means that the system may be unavailable for writes even though some servers are available. System administrators can still use manual failover commmands to promote a standby even if it results in transaction loss.
|
||||
|
||||
Turning on ``synchronous_mode`` does not guarantee multi node durability of commits under all circumstances. When no suitable standby is available, master server will still accept writes, but does not guarantee their replication. When the master fails in this mode no standby will be promote. When the host that used to be master comes back it will get promoted automatically, unless system administrator performed a manual failover. This behavior makes synchronous mode usable with 2 node clusters.
|
||||
|
||||
When ``synchronous_mode`` is on and a standby crashes, commits will block until next iteration of Patroni runs and switches master to standalone mode (worst case delay for writes ``ttl`` seconds, average case ``loop_wait``/2 seconds). Manually shutting down or restarting a standby will not cause a commit service interruption. Standby will signal the master to release itself from synchronous standby duties before PostgreSQL shutdown is initiated.
|
||||
|
||||
You can ensure that a standby never becomes the synchronous standby by setting ``nosync`` tag to true. This is recommended to set for standbys that are behind slow network connections and would cause performance degradation when becoming a synchronous standby.
|
||||
|
||||
Synchronous mode can be switched on and off via Patroni REST interface. See `dynamic configuration <https://github.com/zalando/patroni/blob/master/docs/dynamic_configuration.rst>`__ for instructions.
|
||||
|
||||
|
||||
Synchronous mode implementation
|
||||
-------------------------------
|
||||
|
||||
When in synchronous mode Patroni maintains synchronization state in the DCS, containing the latest master and current synchronous standby. This state is updated with strict ordering constraints to ensure the following invariants:
|
||||
|
||||
- A node must be marked as the latest leader whenever it can accept write transactions. Patroni crashing or PostgreSQL not shutting down can cause violations of this invariant.
|
||||
|
||||
- A node must be set as the synchronous standby in PostgreSQL as long as it is published as the synchronous standby.
|
||||
|
||||
- A node that is not the leader or current synchronous standby is not allowed to promote itself automatically.
|
||||
|
||||
Patroni will only ever assign one standby to ``synchronous_standby_names`` because with multiple candidates it is not possible to know which node was acting as synchronous during the failure.
|
||||
|
||||
On each HA loop iteration Patroni re-evaluates synchronous standby choice. If the current synchronous standby is connected and has not requested its synchronous status to be removed it remains picked. Otherwise the cluster member avaiable for sync that is furthest ahead in replication is picked.
|
||||
|
||||
|
||||
.. [1] The data is still there, but recovering it requires a manual recovery effort by data recovery specialists. When Patroni is allowed to rewind with ``use_pg_rewind`` the forked timeline will be automatically erased to rejoin the failed master with the cluster.
|
||||
|
||||
.. [2] Clients can change the behavior per transaction using PostgreSQL's ``synchronous_commit`` setting. Transactions with ``synchronous_commit`` values of ``off`` and ``local`` may be lost on fail over, but will not be blocked by replication delays.
|
||||
@@ -0,0 +1,13 @@
|
||||
### confd
|
||||
|
||||
`confd` directory contains haproxy template files for the [confd](https://github.com/kelseyhightower/confd) -- lightweight configuration management tool
|
||||
You need to copy content of `confd` directory into /etcd/confd and run confd service:
|
||||
```bash
|
||||
$ confd -prefix=/service/$PATRONI_SCOPE -backend etcd -node $PATRONI_ETCD_HOST -interval=10
|
||||
```
|
||||
It will periodically update haproxy.cfg with the actual list of Patroni nodes from `etcd` and "reload" haproxy when it is necessary.
|
||||
|
||||
|
||||
### startup-scripts
|
||||
|
||||
`startup-scripts` directory contains startup scripts for various OSes and management tools for Patroni.
|
||||
@@ -0,0 +1,13 @@
|
||||
[template]
|
||||
#prefix = "/service/batman"
|
||||
#owner = "haproxy"
|
||||
#mode = "0644"
|
||||
src = "haproxy.tmpl"
|
||||
dest = "/etc/haproxy/haproxy.cfg"
|
||||
|
||||
check_cmd = "/usr/sbin/haproxy -c -f {{ .src }}"
|
||||
reload_cmd = "haproxy -f /etc/haproxy/haproxy.cfg -p /var/run/haproxy.pid -D -sf $(cat /var/run/haproxy.pid)"
|
||||
|
||||
keys = [
|
||||
"/members/",
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
global
|
||||
maxconn 100
|
||||
|
||||
defaults
|
||||
log global
|
||||
mode tcp
|
||||
retries 2
|
||||
timeout client 30m
|
||||
timeout connect 4s
|
||||
timeout server 30m
|
||||
timeout check 5s
|
||||
|
||||
frontend master_postgresql
|
||||
bind *:5000
|
||||
default_backend backend_master
|
||||
|
||||
frontend replicas_postgresql
|
||||
bind *:5001
|
||||
default_backend backend_replicas
|
||||
|
||||
backend backend_master
|
||||
option httpchk OPTIONS /master
|
||||
{{range gets "/members/*"}} server {{base .Key}} {{$data := json .Value}}{{base (replace (index (split $data.conn_url "/") 2) "@" "/" -1)}} maxconn 100 check port {{index (split (index (split $data.api_url "/") 2) ":") 1}}
|
||||
{{end}}
|
||||
backend backend_replicas
|
||||
option httpchk OPTIONS /replica
|
||||
{{range gets "/members/*"}} server {{base .Key}} {{$data := json .Value}}{{base (replace (index (split $data.conn_url "/") 2) "@" "/" -1)}} maxconn 100 check port {{index (split (index (split $data.api_url "/") 2) ":") 1}}
|
||||
{{end}}
|
||||
@@ -8,3 +8,6 @@ Scripts supplied:
|
||||
### patroni.upstart.conf
|
||||
|
||||
Upstart job for Ubuntu 12.04 or 14.04. Requires Upstart > 1.4. Intended for systems where Patroni has been installed on a base system, rather than in Docker.
|
||||
|
||||
### patroni.service
|
||||
Systemd service file, to be copied to /etc/systemd/system/patroni.service, tested on Centos 7.1 with Patroni installed from pip.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# This is an example systemd config file for Patroni
|
||||
# You can copy it to "/etc/systemd/system/patroni.service",
|
||||
|
||||
[Unit]
|
||||
Description=Runners to orchestrate a high-availability PostgreSQL
|
||||
After=syslog.target network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
User=postgres
|
||||
Group=postgres
|
||||
|
||||
# Where to send early-startup messages from the server
|
||||
# This is normally controlled by the global default set by systemd
|
||||
# StandardOutput=syslog
|
||||
|
||||
ExecStart=/bin/patroni /etc/patroni.yml
|
||||
|
||||
# only kill the patroni process, not it's children, so it will gracefully stop postgres
|
||||
KillMode=process
|
||||
|
||||
# Give a reasonable amount of time for the server to start up/shut down
|
||||
TimeoutSec=10
|
||||
|
||||
# Do not restart the service if it crashes, we want to manually inspect database on failure
|
||||
Restart=no
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
Feature: basic replication
|
||||
We should check that the basic bootstrapping, replication and failover works.
|
||||
|
||||
Scenario: check replication of a single table
|
||||
Given I start postgres0
|
||||
Then postgres0 is a leader after 10 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "loop_wait": 2, "synchronous_mode": true}
|
||||
Then I receive a response code 200
|
||||
When I start postgres1
|
||||
And I configure and start postgres2 with a tag replicatefrom postgres0
|
||||
And "sync" key in DCS has leader=postgres0 after 20 seconds
|
||||
And I add the table foo to postgres0
|
||||
Then table foo is present on postgres1 after 20 seconds
|
||||
Then table foo is present on postgres2 after 20 seconds
|
||||
|
||||
Scenario: check restart of sync replica
|
||||
Given I run patronictl.py restart batman postgres2 --force
|
||||
And "sync" key in DCS has sync_standby=postgres1 after 2 seconds
|
||||
And I run patronictl.py restart batman postgres1 --force
|
||||
Then I receive a response returncode 0
|
||||
And "sync" key in DCS has sync_standby=postgres2 after 10 seconds
|
||||
|
||||
Scenario: check the basic failover in synchronous mode
|
||||
When I kill postgres0
|
||||
Then postgres2 role is the primary after 22 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8009/config with {"synchronous_mode": null, "master_start_timeout": 0}
|
||||
Then I receive a response code 200
|
||||
When I add the table bar to postgres2
|
||||
Then table bar is present on postgres1 after 20 seconds
|
||||
|
||||
Scenario: check immediate failover when master_start_timeout=0
|
||||
Given I kill postmaster on postgres2
|
||||
Then postgres1 is a leader after 10 seconds
|
||||
And postgres1 role is the primary after 10 seconds
|
||||
|
||||
Scenario: check rejoin of the former master with pg_rewind
|
||||
Given I start postgres0
|
||||
Then postgres0 role is the secondary after 20 seconds
|
||||
When I add the table buz to postgres1
|
||||
Then table buz is present on postgres0 after 20 seconds
|
||||
@@ -0,0 +1,14 @@
|
||||
Feature: cascading replication
|
||||
We should check that patroni can do base backup and streaming from the replica
|
||||
|
||||
Scenario: check a base backup and streaming replication from a replica
|
||||
Given I start postgres0
|
||||
And postgres0 is a leader after 10 seconds
|
||||
And I configure and start postgres1 with a tag clonefrom true
|
||||
And replication works from postgres0 to postgres1 after 20 seconds
|
||||
And I create label with "postgres0" in postgres0 data directory
|
||||
And I create label with "postgres1" in postgres1 data directory
|
||||
And "members/postgres1" key in DCS has state=running after 12 seconds
|
||||
And I configure and start postgres2 with a tag replicatefrom postgres1
|
||||
Then replication works from postgres0 to postgres2 after 30 seconds
|
||||
And there is a label with "postgres1" in postgres2 data directory
|
||||
@@ -0,0 +1,448 @@
|
||||
import abc
|
||||
import consul
|
||||
import etcd
|
||||
import kazoo.client
|
||||
import kazoo.exceptions
|
||||
import os
|
||||
import psycopg2
|
||||
import shutil
|
||||
import six
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import yaml
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class AbstractController(object):
|
||||
|
||||
def __init__(self, context, name, work_directory, output_dir):
|
||||
self._context = context
|
||||
self._name = name
|
||||
self._work_directory = work_directory
|
||||
self._output_dir = output_dir
|
||||
self._handle = None
|
||||
self._log = None
|
||||
|
||||
def _has_started(self):
|
||||
return self._handle and self._handle.pid and self._handle.poll() is None
|
||||
|
||||
def _is_running(self):
|
||||
return self._has_started()
|
||||
|
||||
@abc.abstractmethod
|
||||
def _is_accessible(self):
|
||||
"""process is accessible for queries"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _start(self):
|
||||
"""start process"""
|
||||
|
||||
def start(self, max_wait_limit=5):
|
||||
if self._is_running():
|
||||
return True
|
||||
|
||||
self._log = open(os.path.join(self._output_dir, self._name + '.log'), 'a')
|
||||
self._handle = self._start()
|
||||
|
||||
assert self._has_started(), "Process {0} is not running after being started".format(self._name)
|
||||
|
||||
max_wait_limit *= self._context.timeout_multiplier
|
||||
for _ in range(max_wait_limit):
|
||||
if self._is_accessible():
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
assert False,\
|
||||
"{0} instance is not available for queries after {1} seconds".format(self._name, max_wait_limit)
|
||||
|
||||
def stop(self, kill=False, timeout=15, _=False):
|
||||
term = False
|
||||
start_time = time.time()
|
||||
|
||||
timeout *= self._context.timeout_multiplier
|
||||
while self._handle and self._is_running():
|
||||
if kill:
|
||||
self._handle.kill()
|
||||
elif not term:
|
||||
self._handle.terminate()
|
||||
term = True
|
||||
time.sleep(1)
|
||||
if not kill and time.time() - start_time > timeout:
|
||||
kill = True
|
||||
|
||||
if self._log:
|
||||
self._log.close()
|
||||
|
||||
|
||||
class PatroniController(AbstractController):
|
||||
__PORT = 5440
|
||||
PATRONI_CONFIG = '{}.yml'
|
||||
""" starts and stops individual patronis"""
|
||||
|
||||
def __init__(self, context, name, work_directory, output_dir, tags=None):
|
||||
super(PatroniController, self).__init__(context, 'patroni_' + name, work_directory, output_dir)
|
||||
PatroniController.__PORT += 1
|
||||
self._data_dir = os.path.join(work_directory, 'data', name)
|
||||
self._connstring = None
|
||||
self._config = self._make_patroni_test_config(name, tags)
|
||||
|
||||
self._conn = None
|
||||
self._curs = None
|
||||
|
||||
def write_label(self, content):
|
||||
with open(os.path.join(self._data_dir, 'label'), 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
def read_label(self):
|
||||
try:
|
||||
with open(os.path.join(self._data_dir, 'label'), 'r') as f:
|
||||
return f.read().strip()
|
||||
except IOError:
|
||||
return None
|
||||
|
||||
def add_tag_to_config(self, tag, value):
|
||||
with open(self._config) as r:
|
||||
config = yaml.safe_load(r)
|
||||
config['tags']['tag'] = value
|
||||
with open(self._config, 'w') as w:
|
||||
yaml.safe_dump(config, w, default_flow_style=False)
|
||||
|
||||
def _start(self):
|
||||
return subprocess.Popen(['coverage', 'run', '--source=patroni', '-p', 'patroni.py', self._config],
|
||||
stdout=self._log, stderr=subprocess.STDOUT, cwd=self._work_directory)
|
||||
|
||||
def stop(self, kill=False, timeout=15, postgres=False):
|
||||
if postgres:
|
||||
return subprocess.call(['pg_ctl', '-D', self._data_dir, 'stop', '-mi', '-w'])
|
||||
super(PatroniController, self).stop(kill, timeout)
|
||||
|
||||
def _is_accessible(self):
|
||||
return self.query("SELECT 1", fail_ok=True) is not None
|
||||
|
||||
def _make_patroni_test_config(self, name, tags):
|
||||
patroni_config_name = self.PATRONI_CONFIG.format(name)
|
||||
patroni_config_path = os.path.join(self._output_dir, patroni_config_name)
|
||||
|
||||
with open(patroni_config_name) as f:
|
||||
config = yaml.safe_load(f)
|
||||
config.pop('etcd')
|
||||
|
||||
host = config['postgresql']['listen'].split(':')[0]
|
||||
|
||||
config['postgresql']['listen'] = config['postgresql']['connect_address'] = '{0}:{1}'.format(host, self.__PORT)
|
||||
|
||||
user = config['postgresql'].get('authentication', config['postgresql']).get('superuser', {})
|
||||
self._connkwargs = {k: user[n] for n, k in [('username', 'user'), ('password', 'password')] if n in user}
|
||||
self._connkwargs.update({'host': host, 'port': self.__PORT, 'database': 'postgres'})
|
||||
|
||||
config['name'] = name
|
||||
config['postgresql']['data_dir'] = self._data_dir
|
||||
config['postgresql']['parameters'].update({
|
||||
'logging_collector': 'on', 'log_destination': 'csvlog', 'log_directory': self._output_dir,
|
||||
'log_filename': name + '.log', 'log_statement': 'all', 'log_min_messages': 'debug1'})
|
||||
|
||||
if 'bootstrap' in config and 'initdb' in config['bootstrap']:
|
||||
config['bootstrap']['initdb'].extend([{'auth': 'md5'}, {'auth-host': 'md5'}])
|
||||
|
||||
if tags:
|
||||
config['tags'] = tags
|
||||
|
||||
with open(patroni_config_path, 'w') as f:
|
||||
yaml.safe_dump(config, f, default_flow_style=False)
|
||||
|
||||
return patroni_config_path
|
||||
|
||||
def _connection(self):
|
||||
if not self._conn or self._conn.closed != 0:
|
||||
self._conn = psycopg2.connect(**self._connkwargs)
|
||||
self._conn.autocommit = True
|
||||
return self._conn
|
||||
|
||||
def _cursor(self):
|
||||
if not self._curs or self._curs.closed or self._curs.connection.closed != 0:
|
||||
self._curs = self._connection().cursor()
|
||||
return self._curs
|
||||
|
||||
def query(self, query, fail_ok=False):
|
||||
try:
|
||||
cursor = self._cursor()
|
||||
cursor.execute(query)
|
||||
return cursor
|
||||
except psycopg2.Error:
|
||||
if not fail_ok:
|
||||
raise
|
||||
|
||||
def check_role_has_changed_to(self, new_role, timeout=10):
|
||||
bound_time = time.time() + timeout
|
||||
recovery_status = new_role != 'primary'
|
||||
while time.time() < bound_time:
|
||||
cur = self.query("SELECT pg_is_in_recovery()", fail_ok=True)
|
||||
if cur:
|
||||
row = cur.fetchone()
|
||||
if row and row[0] == recovery_status:
|
||||
return True
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
class AbstractDcsController(AbstractController):
|
||||
|
||||
_CLUSTER_NODE = '/service/batman'
|
||||
|
||||
def __init__(self, context, mktemp=True):
|
||||
work_directory = mktemp and tempfile.mkdtemp() or None
|
||||
super(AbstractDcsController, self).__init__(context, self.name(), work_directory, context.pctl.output_dir)
|
||||
|
||||
def _is_accessible(self):
|
||||
return self._is_running()
|
||||
|
||||
def stop(self, kill=False, timeout=15):
|
||||
""" terminate process and wipe out the temp work directory, but only if we actually started it"""
|
||||
super(AbstractDcsController, self).stop(kill=kill, timeout=timeout)
|
||||
if self._work_directory:
|
||||
shutil.rmtree(self._work_directory)
|
||||
|
||||
def path(self, key=None):
|
||||
return self._CLUSTER_NODE + (key and '/' + key or '')
|
||||
|
||||
@abc.abstractmethod
|
||||
def query(self, key):
|
||||
""" query for a value of a given key """
|
||||
|
||||
@abc.abstractmethod
|
||||
def set(self, key, value):
|
||||
""" set a value to a given key """
|
||||
|
||||
@abc.abstractmethod
|
||||
def cleanup_service_tree(self):
|
||||
""" clean all contents stored in the tree used for the tests """
|
||||
|
||||
@classmethod
|
||||
def get_subclasses(cls):
|
||||
for subclass in cls.__subclasses__():
|
||||
for subsubclass in subclass.get_subclasses():
|
||||
yield subsubclass
|
||||
yield subclass
|
||||
|
||||
@classmethod
|
||||
def name(cls):
|
||||
return cls.__name__[:-10].lower()
|
||||
|
||||
|
||||
class ConsulController(AbstractDcsController):
|
||||
|
||||
def __init__(self, context):
|
||||
super(ConsulController, self).__init__(context)
|
||||
os.environ['PATRONI_CONSUL_HOST'] = 'localhost:8500'
|
||||
self._client = consul.Consul()
|
||||
|
||||
def _start(self):
|
||||
config_file = self._work_directory + '.json'
|
||||
with open(config_file, 'wb') as f:
|
||||
f.write(b'{"session_ttl_min":"5s","server":true,"bootstrap":true,"advertise_addr":"127.0.0.1"}')
|
||||
return subprocess.Popen(['consul', 'agent', '-config-file', config_file, '-data-dir', self._work_directory],
|
||||
stdout=self._log, stderr=subprocess.STDOUT)
|
||||
|
||||
def stop(self, kill=False, timeout=15):
|
||||
super(ConsulController, self).stop(kill=kill, timeout=timeout)
|
||||
if self._work_directory:
|
||||
os.unlink(self._work_directory + '.json')
|
||||
|
||||
def _is_running(self):
|
||||
try:
|
||||
return bool(self._client.status.leader())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def path(self, key=None):
|
||||
return super(ConsulController, self).path(key)[1:]
|
||||
|
||||
def query(self, key):
|
||||
_, value = self._client.kv.get(self.path(key))
|
||||
return value and value['Value'].decode('utf-8')
|
||||
|
||||
def set(self, key, value):
|
||||
self._client.kv.put(self.path(key), value)
|
||||
|
||||
def cleanup_service_tree(self):
|
||||
self._client.kv.delete(self.path(), recurse=True)
|
||||
|
||||
def start(self, max_wait_limit=15):
|
||||
super(ConsulController, self).start(max_wait_limit)
|
||||
|
||||
|
||||
class EtcdController(AbstractDcsController):
|
||||
|
||||
""" handles all etcd related tasks, used for the tests setup and cleanup """
|
||||
|
||||
def __init__(self, context):
|
||||
super(EtcdController, self).__init__(context)
|
||||
os.environ['PATRONI_ETCD_HOST'] = 'localhost:2379'
|
||||
self._client = etcd.Client(port=2379)
|
||||
|
||||
def _start(self):
|
||||
return subprocess.Popen(["etcd", "--debug", "--data-dir", self._work_directory],
|
||||
stdout=self._log, stderr=subprocess.STDOUT)
|
||||
|
||||
def query(self, key):
|
||||
try:
|
||||
return self._client.get(self.path(key)).value
|
||||
except etcd.EtcdKeyNotFound:
|
||||
return None
|
||||
|
||||
def set(self, key, value):
|
||||
self._client.set(self.path(key), value)
|
||||
|
||||
def cleanup_service_tree(self):
|
||||
try:
|
||||
self._client.delete(self.path(), recursive=True)
|
||||
except (etcd.EtcdKeyNotFound, etcd.EtcdConnectionFailed):
|
||||
return
|
||||
except Exception as e:
|
||||
assert False, "exception when cleaning up etcd contents: {0}".format(e)
|
||||
|
||||
def _is_running(self):
|
||||
# if etcd is running, but we didn't start it
|
||||
try:
|
||||
return bool(self._client.machines)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class ZooKeeperController(AbstractDcsController):
|
||||
|
||||
""" handles all zookeeper related tasks, used for the tests setup and cleanup """
|
||||
|
||||
def __init__(self, context, export_env=True):
|
||||
super(ZooKeeperController, self).__init__(context, False)
|
||||
if export_env:
|
||||
os.environ['PATRONI_ZOOKEEPER_HOSTS'] = "'localhost:2181'"
|
||||
self._client = kazoo.client.KazooClient()
|
||||
|
||||
def _start(self):
|
||||
pass # TODO: implement later
|
||||
|
||||
def query(self, key):
|
||||
try:
|
||||
return self._client.get(self.path(key))[0].decode('utf-8')
|
||||
except kazoo.exceptions.NoNodeError:
|
||||
return None
|
||||
|
||||
def set(self, key, value):
|
||||
self._client.set(self.path(key), value.encode('utf-8'))
|
||||
|
||||
def cleanup_service_tree(self):
|
||||
try:
|
||||
self._client.delete(self.path(), recursive=True)
|
||||
except (kazoo.exceptions.NoNodeError):
|
||||
return
|
||||
except Exception as e:
|
||||
assert False, "exception when cleaning up zookeeper contents: {0}".format(e)
|
||||
|
||||
def _is_running(self):
|
||||
# if zookeeper is running, but we didn't start it
|
||||
if self._client.connected:
|
||||
return True
|
||||
try:
|
||||
return self._client.start(1) or True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class ExhibitorController(ZooKeeperController):
|
||||
|
||||
def __init__(self, context):
|
||||
super(ExhibitorController, self).__init__(context, False)
|
||||
os.environ.update({'PATRONI_EXHIBITOR_HOSTS': 'localhost', 'PATRONI_EXHIBITOR_PORT': '8181'})
|
||||
|
||||
|
||||
class PatroniPoolController(object):
|
||||
|
||||
def __init__(self, context):
|
||||
self._context = context
|
||||
self._dcs = None
|
||||
self._output_dir = None
|
||||
self._patroni_path = None
|
||||
self._processes = {}
|
||||
self.create_and_set_output_directory('')
|
||||
self.known_dcs = {subclass.name(): subclass for subclass in AbstractDcsController.get_subclasses()}
|
||||
|
||||
@property
|
||||
def patroni_path(self):
|
||||
if self._patroni_path is None:
|
||||
cwd = os.path.realpath(__file__)
|
||||
while True:
|
||||
cwd, entry = os.path.split(cwd)
|
||||
if entry == 'features' or cwd == '/':
|
||||
break
|
||||
self._patroni_path = cwd
|
||||
return self._patroni_path
|
||||
|
||||
@property
|
||||
def output_dir(self):
|
||||
return self._output_dir
|
||||
|
||||
def start(self, name, max_wait_limit=20, tags=None):
|
||||
if name not in self._processes:
|
||||
self._processes[name] = PatroniController(self._context, name, self.patroni_path, self._output_dir, tags)
|
||||
self._processes[name].start(max_wait_limit)
|
||||
|
||||
def __getattr__(self, func):
|
||||
if func not in ['stop', 'query', 'write_label', 'read_label', 'check_role_has_changed_to', 'add_tag_to_config']:
|
||||
raise AttributeError("PatroniPoolController instance has no attribute '{0}'".format(func))
|
||||
|
||||
def wrapper(name, *args, **kwargs):
|
||||
return getattr(self._processes[name], func)(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
def stop_all(self):
|
||||
for ctl in self._processes.values():
|
||||
ctl.stop()
|
||||
self._processes.clear()
|
||||
|
||||
def create_and_set_output_directory(self, feature_name):
|
||||
feature_dir = os.path.join(self.patroni_path, 'features/output', feature_name.replace(' ', '_'))
|
||||
if os.path.exists(feature_dir):
|
||||
shutil.rmtree(feature_dir)
|
||||
os.makedirs(feature_dir)
|
||||
self._output_dir = feature_dir
|
||||
|
||||
@property
|
||||
def dcs(self):
|
||||
if self._dcs is None:
|
||||
self._dcs = os.environ.pop('DCS', 'etcd')
|
||||
assert self._dcs in self.known_dcs, 'Unsupported dcs: ' + self._dcs
|
||||
return self._dcs
|
||||
|
||||
|
||||
# actions to execute on start/stop of the tests and before running invidual features
|
||||
def before_all(context):
|
||||
context.ci = 'TRAVIS_BUILD_NUMBER' in os.environ or 'BUILD_NUMBER' in os.environ
|
||||
context.timeout_multiplier = 2 if context.ci else 1
|
||||
context.pctl = PatroniPoolController(context)
|
||||
context.dcs_ctl = context.pctl.known_dcs[context.pctl.dcs](context)
|
||||
context.dcs_ctl.start()
|
||||
try:
|
||||
context.dcs_ctl.cleanup_service_tree()
|
||||
except AssertionError: # after_all handlers won't be executed in before_all
|
||||
context.dcs_ctl.stop()
|
||||
raise
|
||||
|
||||
|
||||
def after_all(context):
|
||||
context.dcs_ctl.stop()
|
||||
subprocess.call(['coverage', 'combine'])
|
||||
subprocess.call(['coverage', 'report'])
|
||||
|
||||
|
||||
def before_feature(context, feature):
|
||||
""" create per-feature output directory to collect Patroni and PostgreSQL logs """
|
||||
context.pctl.create_and_set_output_directory(feature.name)
|
||||
|
||||
|
||||
def after_feature(context, feature):
|
||||
""" stop all Patronis, remove their data directory and cleanup the keys in etcd """
|
||||
context.pctl.stop_all()
|
||||
shutil.rmtree(os.path.join(context.pctl.patroni_path, 'data'))
|
||||
context.dcs_ctl.cleanup_service_tree()
|
||||
@@ -0,0 +1,99 @@
|
||||
Feature: patroni api
|
||||
We should check that patroni correctly responds to valid and not-valid API requests.
|
||||
|
||||
Scenario: check API requests on a stand-alone server
|
||||
Given I start postgres0
|
||||
And postgres0 is a leader after 10 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8008/
|
||||
Then I receive a response code 200
|
||||
And I receive a response state running
|
||||
And I receive a response role master
|
||||
When I issue a GET request to http://127.0.0.1:8008/replica
|
||||
Then I receive a response code 503
|
||||
When I run patronictl.py reinit batman postgres0 --force
|
||||
Then I receive a response returncode 0
|
||||
And I receive a response output "Failed: reinitialize for member postgres0, status code=503, (I am the leader, can not reinitialize)"
|
||||
When I run patronictl.py failover batman --master postgres0 --force
|
||||
Then I receive a response returncode 1
|
||||
And I receive a response output "Error: No candidates found to failover to"
|
||||
When I issue a POST request to http://127.0.0.1:8008/failover with {"leader": "postgres0"}
|
||||
Then I receive a response code 500
|
||||
And I receive a response text failover is not possible: cluster does not have members except leader
|
||||
When I issue an empty POST request to http://127.0.0.1:8008/failover
|
||||
Then I receive a response code 400
|
||||
When I issue a POST request to http://127.0.0.1:8008/failover with {"foo": "bar"}
|
||||
Then I receive a response code 400
|
||||
And I receive a response text "No values given for required parameters leader and candidate"
|
||||
|
||||
Scenario: check local configuration reload
|
||||
Given I issue an empty POST request to http://127.0.0.1:8008/reload
|
||||
Then I receive a response code 200
|
||||
And I receive a response text nothing changed
|
||||
When I add tag new_tag new_value to postgres0 config
|
||||
And I issue an empty POST request to http://127.0.0.1:8008/reload
|
||||
Then I receive a response code 202
|
||||
|
||||
Scenario: check dynamic configuration change via DCS
|
||||
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 10, "loop_wait": 2, "postgresql": {"parameters": {"max_connections": 101}}}
|
||||
Then I receive a response code 200
|
||||
And I receive a response loop_wait 2
|
||||
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 11 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8008/config
|
||||
Then I receive a response code 200
|
||||
And I receive a response loop_wait 2
|
||||
When I issue a GET request to http://127.0.0.1:8008/patroni
|
||||
Then I receive a response code 200
|
||||
And I receive a response tags {'tag': 'new_value'}
|
||||
|
||||
Scenario: check API requests for the primary-replica pair in the pause mode
|
||||
Given I run patronictl.py pause batman
|
||||
Then I receive a response returncode 0
|
||||
When I start postgres1
|
||||
Then replication works from postgres0 to postgres1 after 20 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8009/replica
|
||||
Then I receive a response code 200
|
||||
And I receive a response state running
|
||||
And I receive a response role replica
|
||||
When I run patronictl.py reinit batman postgres1 --force
|
||||
Then I receive a response returncode 0
|
||||
And I receive a response output "Success: reinitialize for member postgres1"
|
||||
When I run patronictl.py restart batman postgres0 --force
|
||||
Then I receive a response returncode 0
|
||||
And I receive a response output "Success: restart on member postgres0"
|
||||
And postgres0 role is the primary after 5 seconds
|
||||
When I sleep for 10 seconds
|
||||
Then postgres1 role is the secondary after 15 seconds
|
||||
|
||||
Scenario: check the failover via the API in the pause mode
|
||||
Given I run patronictl.py failover batman --master postgres0 --candidate postgres1 --force
|
||||
Then I receive a response returncode 0
|
||||
And postgres1 is a leader after 5 seconds
|
||||
And postgres1 role is the primary after 10 seconds
|
||||
And postgres0 role is the secondary after 10 seconds
|
||||
And replication works from postgres1 to postgres0 after 20 seconds
|
||||
|
||||
Scenario: check the scheduled failover
|
||||
Given I issue a scheduled failover from postgres1 to postgres0 in 3 seconds
|
||||
Then I receive a response returncode 1
|
||||
And I receive a response output "Can't schedule failover in the paused state"
|
||||
When I run patronictl.py resume batman
|
||||
Then I receive a response returncode 0
|
||||
Given I issue a scheduled failover from postgres1 to postgres0 in 3 seconds
|
||||
Then I receive a response returncode 0
|
||||
And postgres0 is a leader after 20 seconds
|
||||
And postgres0 role is the primary after 10 seconds
|
||||
And postgres1 role is the secondary after 10 seconds
|
||||
And replication works from postgres0 to postgres1 after 25 seconds
|
||||
|
||||
Scenario: check the scheduled restart
|
||||
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"superuser_reserved_connections": "6"}}}
|
||||
Then I receive a response code 200
|
||||
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 5 seconds
|
||||
Given I issue a scheduled restart at http://127.0.0.1:8008 in 3 seconds with {"role": "replica"}
|
||||
Then I receive a response code 202
|
||||
And I sleep for 4 seconds
|
||||
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 10 seconds
|
||||
Given I issue a scheduled restart at http://127.0.0.1:8008 in 3 seconds with {"restart_pending": "True"}
|
||||
Then I receive a response code 202
|
||||
And Response on GET http://127.0.0.1:8008/patroni does not contain pending_restart after 10 seconds
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import psycopg2 as pg
|
||||
|
||||
from behave import step, then
|
||||
from time import sleep, time
|
||||
|
||||
|
||||
@step('I start {name:w}')
|
||||
def start_patroni(context, name):
|
||||
return context.pctl.start(name)
|
||||
|
||||
|
||||
@step('I shut down {name:w}')
|
||||
def stop_patroni(context, name):
|
||||
return context.pctl.stop(name)
|
||||
|
||||
|
||||
@step('I kill {name:w}')
|
||||
def kill_patroni(context, name):
|
||||
return context.pctl.stop(name, kill=True)
|
||||
|
||||
|
||||
@step('I kill postmaster on {name:w}')
|
||||
def stop_postgres(context, name):
|
||||
return context.pctl.stop(name, postgres=True)
|
||||
|
||||
|
||||
@step('I add the table {table_name:w} to {pg_name:w}')
|
||||
def add_table(context, table_name, pg_name):
|
||||
# parse the configuration file and get the port
|
||||
try:
|
||||
context.pctl.query(pg_name, "CREATE TABLE {0}()".format(table_name))
|
||||
except pg.Error as e:
|
||||
assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e)
|
||||
|
||||
|
||||
@then('Table {table_name:w} is present on {pg_name:w} after {max_replication_delay:d} seconds')
|
||||
def table_is_present_on(context, table_name, pg_name, max_replication_delay):
|
||||
max_replication_delay *= context.timeout_multiplier
|
||||
for _ in range(int(max_replication_delay)):
|
||||
if context.pctl.query(pg_name, "SELECT 1 FROM {0}".format(table_name), fail_ok=True) is not None:
|
||||
break
|
||||
sleep(1)
|
||||
else:
|
||||
assert False,\
|
||||
"Table {0} is not present on {1} after {2} seconds".format(table_name, pg_name, max_replication_delay)
|
||||
|
||||
|
||||
@then('{pg_name:w} role is the {pg_role:w} after {max_promotion_timeout:d} seconds')
|
||||
def check_role(context, pg_name, pg_role, max_promotion_timeout):
|
||||
max_promotion_timeout *= context.timeout_multiplier
|
||||
assert context.pctl.check_role_has_changed_to(pg_name, pg_role, timeout=int(max_promotion_timeout)),\
|
||||
"{0} role didn't change to {1} after {2} seconds".format(pg_name, pg_role, max_promotion_timeout)
|
||||
|
||||
|
||||
@step('replication works from {master:w} to {replica:w} after {time_limit:d} seconds')
|
||||
@then('replication works from {master:w} to {replica:w} after {time_limit:d} seconds')
|
||||
def replication_works(context, master, replica, time_limit):
|
||||
context.execute_steps(u"""
|
||||
When I add the table test_{0} to {1}
|
||||
Then table test_{0} is present on {2} after {3} seconds
|
||||
""".format(int(time()), master, replica, time_limit))
|
||||
@@ -0,0 +1,35 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
from behave import step, then
|
||||
|
||||
|
||||
@step('I configure and start {name:w} with a tag {tag_name:w} {tag_value:w}')
|
||||
def start_patroni_with_a_name_value_tag(context, name, tag_name, tag_value):
|
||||
return context.pctl.start(name, tags={tag_name: tag_value})
|
||||
|
||||
|
||||
@then('There is a label with "{content:w}" in {name:w} data directory')
|
||||
def check_label(context, content, name):
|
||||
label = context.pctl.read_label(name)
|
||||
assert label == content, "{0} is not equal to {1}".format(label, content)
|
||||
|
||||
|
||||
@step('I create label with "{content:w}" in {name:w} data directory')
|
||||
def write_label(context, content, name):
|
||||
context.pctl.write_label(name, content)
|
||||
|
||||
|
||||
@step('"{name}" key in DCS has {key:w}={value:w} after {time_limit:d} seconds')
|
||||
def check_member(context, name, key, value, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
response = json.loads(context.dcs_ctl.query(name))
|
||||
if response.get(key) == value:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, "{0} does not have {1}={2} in dcs after {3} seconds".format(name, key, value, time_limit)
|
||||
@@ -0,0 +1,158 @@
|
||||
import json
|
||||
import os
|
||||
import parse
|
||||
import requests
|
||||
import shlex
|
||||
import subprocess
|
||||
import time
|
||||
import yaml
|
||||
|
||||
from behave import register_type, step, then
|
||||
from dateutil import tz
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
tzutc = tz.tzutc()
|
||||
|
||||
|
||||
@parse.with_pattern(r'https?://(?:\w|\.|:|/)+')
|
||||
def parse_url(text):
|
||||
return text
|
||||
|
||||
|
||||
register_type(url=parse_url)
|
||||
|
||||
|
||||
# there is no way we can find out if the node has already
|
||||
# started as a leader without checking the DCS. We cannot
|
||||
# just rely on the database availability, since there is
|
||||
# a short gap between the time PostgreSQL becomes available
|
||||
# and Patroni assuming the leader role.
|
||||
@step('{name:w} is a leader after {time_limit:d} seconds')
|
||||
@then('{name:w} is a leader after {time_limit:d} seconds')
|
||||
def is_a_leader(context, name, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
while (context.dcs_ctl.query("leader") != name):
|
||||
time.sleep(1)
|
||||
assert time.time() < max_time, "{0} is not a leader in dcs after {1} seconds".format(name, time_limit)
|
||||
|
||||
|
||||
@step('I sleep for {value:d} seconds')
|
||||
def sleep_for_n_seconds(context, value):
|
||||
time.sleep(int(value))
|
||||
|
||||
|
||||
def _set_response(context, response):
|
||||
context.status_code = response.status_code
|
||||
data = response.content.decode('utf-8')
|
||||
ct = response.headers.get('content-type', '')
|
||||
if ct.startswith('application/json') or\
|
||||
ct.startswith('text/yaml') or\
|
||||
ct.startswith('text/x-yaml') or\
|
||||
ct.startswith('application/yaml') or\
|
||||
ct.startswith('application/x-yaml'):
|
||||
try:
|
||||
context.response = yaml.safe_load(data)
|
||||
except ValueError:
|
||||
context.response = data
|
||||
else:
|
||||
context.response = data
|
||||
|
||||
|
||||
@step('I issue a GET request to {url:url}')
|
||||
def do_get(context, url):
|
||||
try:
|
||||
r = requests.get(url)
|
||||
except requests.exceptions.RequestException:
|
||||
context.status_code = None
|
||||
context.response = None
|
||||
else:
|
||||
_set_response(context, r)
|
||||
|
||||
|
||||
@step('I issue an empty POST request to {url:url}')
|
||||
def do_post_empty(context, url):
|
||||
do_request(context, 'POST', url, None)
|
||||
|
||||
|
||||
@step('I issue a {request_method:w} request to {url:url} with {data}')
|
||||
def do_request(context, request_method, url, data):
|
||||
data = data and json.loads(data) or {}
|
||||
try:
|
||||
if request_method == 'PATCH':
|
||||
r = requests.patch(url, json=data)
|
||||
else:
|
||||
r = requests.post(url, json=data)
|
||||
except requests.exceptions.RequestException:
|
||||
context.status_code = None
|
||||
context.response = None
|
||||
else:
|
||||
_set_response(context, r)
|
||||
|
||||
|
||||
@step('I run {cmd}')
|
||||
def do_run(context, cmd):
|
||||
cmd = ['coverage', 'run', '--source=patroni', '-p'] + shlex.split(cmd)
|
||||
try:
|
||||
# XXX: Dirty hack! We need to take name/passwd from the config!
|
||||
env = os.environ.copy()
|
||||
env.update({'PATRONI_RESTAPI_USERNAME': 'username', 'PATRONI_RESTAPI_PASSWORD': 'password'})
|
||||
response = subprocess.check_output(cmd, stderr=subprocess.STDOUT, env=env)
|
||||
context.status_code = 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
response = e.output
|
||||
context.status_code = e.returncode
|
||||
context.response = response.decode('utf-8').strip()
|
||||
|
||||
|
||||
@then('I receive a response {component:w} {data}')
|
||||
def check_response(context, component, data):
|
||||
if component == 'code':
|
||||
assert context.status_code == int(data),\
|
||||
"status code {0} != {1}, response: {2}".format(context.status_code, data, context.response)
|
||||
elif component == 'returncode':
|
||||
assert context.status_code == int(data), "return code {0} != {1}".format(context.status_code, data)
|
||||
elif component == 'text':
|
||||
assert context.response == data.strip('"'), "response {0} does not contain {1}".format(context.response, data)
|
||||
elif component == 'output':
|
||||
assert data.strip('"') in context.response, "response {0} does not contain {1}".format(context.response, data)
|
||||
else:
|
||||
assert component in context.response, "{0} is not part of the response".format(component)
|
||||
assert str(context.response[component]) == str(data), "{0} does not contain {1}".format(component, data)
|
||||
|
||||
|
||||
@step('I issue a scheduled failover from {from_host:w} to {to_host:w} in {in_seconds:d} seconds')
|
||||
def scheduled_failover(context, from_host, to_host, in_seconds):
|
||||
context.execute_steps(u"""
|
||||
Given I run patronictl.py failover batman --master {0} --candidate {1} --scheduled "{2}" --force
|
||||
""".format(from_host, to_host, datetime.now(tzutc) + timedelta(seconds=int(in_seconds))))
|
||||
|
||||
|
||||
@step('I issue a scheduled restart at {url:url} in {in_seconds:d} seconds with {data}')
|
||||
def scheduled_restart(context, url, in_seconds, data):
|
||||
data = data and json.loads(data) or {}
|
||||
data.update(schedule='{0}'.format((datetime.now(tzutc) + timedelta(seconds=int(in_seconds))).isoformat()))
|
||||
context.execute_steps(u"""Given I issue a POST request to {0}/restart with {1}""".format(url, json.dumps(data)))
|
||||
|
||||
|
||||
@step('I add tag {tag:w} {value:w} to {pg_name:w} config')
|
||||
def add_tag_to_config(context, tag, value, pg_name):
|
||||
context.pctl.add_tag_to_config(pg_name, tag, value)
|
||||
|
||||
|
||||
@then('Response on GET {url} contains {value} after {timeout:d} seconds')
|
||||
def check_http_response(context, url, value, timeout, negate=False):
|
||||
timeout *= context.timeout_multiplier
|
||||
for _ in range(int(timeout)):
|
||||
r = requests.get(url)
|
||||
if (value in r.content.decode('utf-8')) != negate:
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
assert False,\
|
||||
"Value {0} is {1} present in response after {2} seconds".format(value, "not" if not negate else "", timeout)
|
||||
|
||||
|
||||
@then('Response on GET {url} does not contain {value} after {timeout:d} seconds')
|
||||
def check_not_in_http_response(context, url, value, timeout):
|
||||
check_http_response(context, url, value, timeout, negate=True)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
pip install --ignore-installed setuptools==19.2 pyinstaller
|
||||
pyinstaller --clean --onefile patroni.spec
|
||||
@@ -0,0 +1,39 @@
|
||||
# -*- mode: python -*-
|
||||
|
||||
block_cipher = None
|
||||
|
||||
|
||||
def hiddenimports():
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
try:
|
||||
import patroni.dcs
|
||||
return patroni.dcs.dcs_modules()
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
|
||||
|
||||
a = Analysis(['patroni/__main__.py'],
|
||||
pathex=[],
|
||||
binaries=None,
|
||||
datas=None,
|
||||
hiddenimports=hiddenimports(),
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
name='patroni',
|
||||
debug=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=True)
|
||||
+160
-39
@@ -1,79 +1,200 @@
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import yaml
|
||||
|
||||
from patroni.api import RestApiServer
|
||||
from patroni.etcd import Etcd
|
||||
from patroni.ha import Ha
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.utils import setup_signal_handlers, reap_children
|
||||
from patroni.zookeeper import ZooKeeper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Patroni:
|
||||
class Patroni(object):
|
||||
|
||||
def __init__(self, config):
|
||||
self.nap_time = config['loop_wait']
|
||||
self.tags = config.get('tags', dict())
|
||||
self.postgresql = Postgresql(config['postgresql'])
|
||||
self.dcs = self.get_dcs(self.postgresql.name, config)
|
||||
self.api = RestApiServer(self, config['restapi'])
|
||||
def __init__(self):
|
||||
from patroni.api import RestApiServer
|
||||
from patroni.config import Config
|
||||
from patroni.dcs import get_dcs
|
||||
from patroni.ha import Ha
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.version import __version__
|
||||
|
||||
self.setup_signal_handlers()
|
||||
|
||||
self.version = __version__
|
||||
self.config = Config()
|
||||
self.dcs = get_dcs(self.config)
|
||||
self.load_dynamic_configuration()
|
||||
|
||||
self.postgresql = Postgresql(self.config['postgresql'])
|
||||
self.api = RestApiServer(self, self.config['restapi'])
|
||||
self.ha = Ha(self)
|
||||
|
||||
self.tags = self.get_tags()
|
||||
self.next_run = time.time()
|
||||
self.scheduled_restart = {}
|
||||
|
||||
def load_dynamic_configuration(self):
|
||||
from patroni.exceptions import DCSError
|
||||
while True:
|
||||
try:
|
||||
cluster = self.dcs.get_cluster()
|
||||
if cluster and cluster.config:
|
||||
if self.config.set_dynamic_configuration(cluster.config):
|
||||
self.dcs.reload_config(self.config)
|
||||
elif not self.config.dynamic_configuration and 'bootstrap' in self.config:
|
||||
if self.config.set_dynamic_configuration(self.config['bootstrap']['dcs']):
|
||||
self.dcs.reload_config(self.config)
|
||||
break
|
||||
except DCSError:
|
||||
logger.warning('Can not get cluster from dcs')
|
||||
|
||||
def get_tags(self):
|
||||
return {tag: value for tag, value in self.config.get('tags', {}).items()
|
||||
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
|
||||
|
||||
@property
|
||||
def nofailover(self):
|
||||
return self.tags.get('nofailover', False)
|
||||
return bool(self.tags.get('nofailover', False))
|
||||
|
||||
@staticmethod
|
||||
def get_dcs(name, config):
|
||||
if 'etcd' in config:
|
||||
return Etcd(name, config['etcd'])
|
||||
if 'zookeeper' in config:
|
||||
return ZooKeeper(name, config['zookeeper'])
|
||||
raise Exception('Can not find suitable configuration of distributed configuration store')
|
||||
@property
|
||||
def nosync(self):
|
||||
return bool(self.tags.get('nosync', False))
|
||||
|
||||
def reload_config(self):
|
||||
try:
|
||||
self.tags = self.get_tags()
|
||||
self.dcs.reload_config(self.config)
|
||||
self.api.reload_config(self.config['restapi'])
|
||||
self.postgresql.reload_config(self.config['postgresql'])
|
||||
except Exception:
|
||||
logger.exception('Failed to reload config_file=%s', self.config.config_file)
|
||||
|
||||
@property
|
||||
def replicatefrom(self):
|
||||
return self.tags.get('replicatefrom')
|
||||
|
||||
def sighup_handler(self, *args):
|
||||
self._received_sighup = True
|
||||
|
||||
def sigterm_handler(self, *args):
|
||||
if not self._received_sigterm:
|
||||
self._received_sigterm = True
|
||||
sys.exit()
|
||||
|
||||
@property
|
||||
def noloadbalance(self):
|
||||
return bool(self.tags.get('noloadbalance', False))
|
||||
|
||||
def schedule_next_run(self):
|
||||
self.next_run += self.nap_time
|
||||
self.next_run += self.dcs.loop_wait
|
||||
current_time = time.time()
|
||||
nap_time = self.next_run - current_time
|
||||
if nap_time <= 0:
|
||||
self.next_run = current_time
|
||||
elif self.dcs.watch(nap_time):
|
||||
# Release the GIL so we don't starve anyone waiting on async_executor lock
|
||||
time.sleep(0.001)
|
||||
# Warn user that Patroni is not keeping up
|
||||
logger.warning("Loop time exceeded, rescheduling immediately.")
|
||||
elif self.ha.watch(nap_time):
|
||||
self.next_run = time.time()
|
||||
|
||||
def run(self):
|
||||
self.api.start()
|
||||
self.next_run = time.time()
|
||||
|
||||
while True:
|
||||
while not self._received_sigterm:
|
||||
if self._received_sighup:
|
||||
self._received_sighup = False
|
||||
if self.config.reload_local_configuration():
|
||||
self.reload_config()
|
||||
|
||||
logger.info(self.ha.run_cycle())
|
||||
reap_children()
|
||||
|
||||
cluster = self.dcs.cluster
|
||||
if cluster and cluster.config and self.config.set_dynamic_configuration(cluster.config):
|
||||
self.reload_config()
|
||||
|
||||
if not self.postgresql.data_directory_empty():
|
||||
self.config.save_cache()
|
||||
|
||||
self.schedule_next_run()
|
||||
|
||||
def setup_signal_handlers(self):
|
||||
self._received_sighup = False
|
||||
self._received_sigterm = False
|
||||
signal.signal(signal.SIGHUP, self.sighup_handler)
|
||||
signal.signal(signal.SIGTERM, self.sigterm_handler)
|
||||
|
||||
def main():
|
||||
|
||||
def patroni_main():
|
||||
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO)
|
||||
logging.getLogger('requests').setLevel(logging.WARNING)
|
||||
setup_signal_handlers()
|
||||
|
||||
if len(sys.argv) < 2 or not os.path.isfile(sys.argv[1]):
|
||||
print('Usage: {} config.yml'.format(sys.argv[0]))
|
||||
return
|
||||
|
||||
with open(sys.argv[1], 'r') as f:
|
||||
config = yaml.load(f)
|
||||
|
||||
patroni = Patroni(config)
|
||||
patroni = Patroni()
|
||||
try:
|
||||
patroni.run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
patroni.api.shutdown()
|
||||
patroni.postgresql.stop()
|
||||
patroni.dcs.delete_leader()
|
||||
if patroni.ha.is_paused():
|
||||
logger.info('Leader key is not deleted and Postgresql is not stopped due paused state')
|
||||
else:
|
||||
patroni.ha.while_not_sync_standby(lambda: patroni.postgresql.stop(checkpoint=False))
|
||||
patroni.dcs.delete_leader()
|
||||
|
||||
|
||||
def pg_ctl_start(args):
|
||||
import subprocess
|
||||
postmaster = subprocess.Popen(args)
|
||||
print(postmaster.pid)
|
||||
|
||||
|
||||
def call_self(args, **kwargs):
|
||||
"""This function executes Patroni once again with provided arguments.
|
||||
|
||||
:args: list of arguments to call Patroni with.
|
||||
:returns: `Popen` object"""
|
||||
|
||||
exe = [sys.executable]
|
||||
if not getattr(sys, 'frozen', False): # Binary distribution?
|
||||
exe.append(sys.argv[0])
|
||||
|
||||
import subprocess
|
||||
return subprocess.Popen(exe + args, **kwargs)
|
||||
|
||||
|
||||
def main():
|
||||
if os.getpid() != 1:
|
||||
if len(sys.argv) > 5 and sys.argv[1] == 'pg_ctl_start':
|
||||
return pg_ctl_start(sys.argv[2:])
|
||||
return patroni_main()
|
||||
|
||||
pid = 0
|
||||
|
||||
# Looks like we are in a docker, so we will act like init
|
||||
def sigchld_handler(signo, stack_frame):
|
||||
try:
|
||||
while True:
|
||||
ret = os.waitpid(-1, os.WNOHANG)
|
||||
if ret == (0, 0):
|
||||
break
|
||||
elif ret[0] != pid:
|
||||
logging.info('Reaped pid=%s, exit status=%s', *ret)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def passtochild(signo, stack_frame):
|
||||
if pid:
|
||||
os.kill(pid, signo)
|
||||
|
||||
signal.signal(signal.SIGCHLD, sigchld_handler)
|
||||
signal.signal(signal.SIGHUP, passtochild)
|
||||
signal.signal(signal.SIGINT, passtochild)
|
||||
signal.signal(signal.SIGUSR1, passtochild)
|
||||
signal.signal(signal.SIGUSR2, passtochild)
|
||||
signal.signal(signal.SIGQUIT, passtochild)
|
||||
signal.signal(signal.SIGTERM, passtochild)
|
||||
|
||||
patroni = call_self(sys.argv[1:])
|
||||
pid = patroni.pid
|
||||
patroni.wait()
|
||||
|
||||
+322
-132
@@ -3,11 +3,12 @@ import fcntl
|
||||
import json
|
||||
import logging
|
||||
import psycopg2
|
||||
import socket
|
||||
import time
|
||||
import dateutil.parser
|
||||
import datetime
|
||||
|
||||
from patroni.exceptions import PostgresConnectionException
|
||||
from patroni.utils import Retry, RetryFailedError
|
||||
from patroni.utils import deep_compare, patch_config, Retry, RetryFailedError, is_valid_pg_version, parse_int, tzutc
|
||||
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
||||
from six.moves.socketserver import ThreadingMixIn
|
||||
from threading import Thread
|
||||
@@ -31,36 +32,49 @@ def check_auth(func):
|
||||
|
||||
class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def send_auth_request(self, body):
|
||||
self.send_response(401)
|
||||
self.send_header('WWW-Authenticate', 'Basic realm=\"Patroni\"')
|
||||
self.send_header('Content-type', 'text/html')
|
||||
def _write_response(self, status_code, body, content_type='text/html', headers=None):
|
||||
self.send_response(status_code)
|
||||
headers = headers or {}
|
||||
if content_type:
|
||||
headers['Content-Type'] = content_type
|
||||
for name, value in headers.items():
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(body.encode('utf-8'))
|
||||
|
||||
def finish(self, *args, **kwargs):
|
||||
try:
|
||||
if not self.wfile.closed:
|
||||
self.wfile.flush()
|
||||
self.wfile.close()
|
||||
except socket.error:
|
||||
pass
|
||||
self.rfile.close()
|
||||
def _write_json_response(self, status_code, response):
|
||||
self._write_response(status_code, json.dumps(response), content_type='application/json')
|
||||
|
||||
def send_auth_request(self, body):
|
||||
headers = {'WWW-Authenticate': 'Basic realm="' + self.server.patroni.__class__.__name__ + '"'}
|
||||
self._write_response(401, body, headers=headers)
|
||||
|
||||
def check_auth_header(self):
|
||||
auth_header = self.headers.get('Authorization')
|
||||
status = self.server.check_auth_header(auth_header)
|
||||
return not status or self.send_auth_request(status)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.do_GET(options=True)
|
||||
def _write_status_response(self, status_code, response):
|
||||
patroni = self.server.patroni
|
||||
tags = patroni.ha.get_effective_tags()
|
||||
if tags:
|
||||
response['tags'] = tags
|
||||
if patroni.postgresql.sysid:
|
||||
response['database_system_identifier'] = patroni.postgresql.sysid
|
||||
if patroni.postgresql.pending_restart:
|
||||
response['pending_restart'] = True
|
||||
response['patroni'] = {'version': patroni.version, 'scope': patroni.postgresql.scope}
|
||||
if patroni.scheduled_restart and isinstance(patroni.scheduled_restart, dict):
|
||||
response['scheduled_restart'] = patroni.scheduled_restart.copy()
|
||||
del response['scheduled_restart']['postmaster_start_time']
|
||||
response['scheduled_restart']['schedule'] = (response['scheduled_restart']['schedule']).isoformat()
|
||||
self._write_json_response(status_code, response)
|
||||
|
||||
def do_GET(self, options=False):
|
||||
def do_GET(self, write_status_code_only=False):
|
||||
"""Default method for processing all GET requests which can not be routed to other methods"""
|
||||
|
||||
path = '/master' if self.path == '/' else self.path
|
||||
response = self.get_postgresql_status()
|
||||
response.update(self.get_tags())
|
||||
|
||||
patroni = self.server.patroni
|
||||
cluster = patroni.dcs.cluster
|
||||
@@ -71,122 +85,268 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
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
|
||||
elif response['role'] in path: # response['role'] != 'master'
|
||||
status_code = 503 if patroni.noloadbalance else 200
|
||||
else:
|
||||
status_code = 503
|
||||
elif 'role' in response and response['role'] in path:
|
||||
status_code = 200
|
||||
status_code = 503 if response['role'] != 'master' and patroni.noloadbalance else 200
|
||||
elif patroni.ha.restart_scheduled() and patroni.postgresql.role == 'master' and 'master' in path:
|
||||
# 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)
|
||||
if not options:
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(response).encode('utf-8'))
|
||||
if write_status_code_only: # when haproxy sends OPTIONS request it reads only status code and nothing more
|
||||
message = self.responses[status_code][0]
|
||||
self.wfile.write('{0} {1} {2}\r\n'.format(self.protocol_version, status_code, message).encode('utf-8'))
|
||||
else:
|
||||
self._write_status_response(status_code, response)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.do_GET(write_status_code_only=True)
|
||||
|
||||
def do_GET_patroni(self):
|
||||
response = self.get_postgresql_status(True)
|
||||
response.update(self.get_tags())
|
||||
self._write_status_response(200, response)
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(response).encode('utf-8'))
|
||||
def do_GET_config(self):
|
||||
cluster = self.server.patroni.dcs.cluster or self.server.patroni.dcs.get_cluster()
|
||||
if cluster.config:
|
||||
self._write_json_response(200, cluster.config.data)
|
||||
else:
|
||||
self.send_error(502)
|
||||
|
||||
def _read_json_content(self, body_is_optional=False):
|
||||
if 'content-length' not in self.headers:
|
||||
return self.send_error(411) if not body_is_optional else {}
|
||||
try:
|
||||
content_length = int(self.headers.get('content-length'))
|
||||
if content_length == 0 and body_is_optional:
|
||||
return {}
|
||||
request = json.loads(self.rfile.read(content_length).decode('utf-8'))
|
||||
if isinstance(request, dict) and (request or body_is_optional):
|
||||
return request
|
||||
except Exception:
|
||||
logger.exception('Bad request')
|
||||
self.send_error(400)
|
||||
|
||||
@check_auth
|
||||
def do_PATCH_config(self):
|
||||
request = self._read_json_content()
|
||||
if request:
|
||||
cluster = self.server.patroni.dcs.get_cluster()
|
||||
data = cluster.config.data.copy()
|
||||
if patch_config(data, request):
|
||||
value = json.dumps(data, separators=(',', ':'))
|
||||
if not self.server.patroni.dcs.set_config_value(value, cluster.config.index):
|
||||
return self.send_error(409)
|
||||
self.server.patroni.ha.wakeup()
|
||||
self._write_json_response(200, data)
|
||||
|
||||
@check_auth
|
||||
def do_PUT_config(self):
|
||||
request = self._read_json_content()
|
||||
if request:
|
||||
cluster = self.server.patroni.dcs.get_cluster()
|
||||
if not deep_compare(request, cluster.config.data):
|
||||
value = json.dumps(request, separators=(',', ':'))
|
||||
if not self.server.patroni.dcs.set_config_value(value):
|
||||
return self.send_error(502)
|
||||
self._write_json_response(200, request)
|
||||
|
||||
@check_auth
|
||||
def do_POST_reload(self):
|
||||
try:
|
||||
if self.server.patroni.config.reload_local_configuration(True):
|
||||
status_code = 202
|
||||
response = 'reload scheduled'
|
||||
self.server.patroni.sighup_handler()
|
||||
else:
|
||||
status_code = 200
|
||||
response = 'nothing changed'
|
||||
except Exception as e:
|
||||
status_code = 500
|
||||
response = str(e)
|
||||
self._write_response(status_code, response)
|
||||
|
||||
@staticmethod
|
||||
def parse_schedule(schedule, action):
|
||||
""" parses the given schedule and validates at """
|
||||
error = None
|
||||
scheduled_at = None
|
||||
try:
|
||||
scheduled_at = dateutil.parser.parse(schedule)
|
||||
if scheduled_at.tzinfo is None:
|
||||
error = 'Timezone information is mandatory for the scheduled {0}'.format(action)
|
||||
status_code = 400
|
||||
elif scheduled_at < datetime.datetime.now(tzutc):
|
||||
error = 'Cannot schedule {0} in the past'.format(action)
|
||||
status_code = 422
|
||||
else:
|
||||
status_code = None
|
||||
except (ValueError, TypeError):
|
||||
logger.exception('Invalid scheduled %s time: %s', action, schedule)
|
||||
error = 'Unable to parse scheduled timestamp. It should be in an unambiguous format, e.g. ISO 8601'
|
||||
status_code = 422
|
||||
return (status_code, error, scheduled_at)
|
||||
|
||||
@check_auth
|
||||
def do_POST_restart(self):
|
||||
status_code = 503
|
||||
data = b'restart failed'
|
||||
try:
|
||||
status, msg = self.server.patroni.ha.restart()
|
||||
status_code = 200 if status else 503
|
||||
data = msg.encode('utf-8')
|
||||
except:
|
||||
logger.exception('Exception during restart')
|
||||
status_code = 500
|
||||
data = 'restart failed'
|
||||
request = self._read_json_content(body_is_optional=True)
|
||||
cluster = self.server.patroni.dcs.get_cluster()
|
||||
if request is None:
|
||||
# failed to parse the json
|
||||
return
|
||||
if request:
|
||||
logger.debug("received restart request: {0}".format(request))
|
||||
|
||||
self.send_response(status_code)
|
||||
self.send_header('Content-Type', 'text/html')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
if cluster.is_paused() and 'schedule' in request:
|
||||
self._write_response(status_code, "Can't schedule restart in the paused state")
|
||||
return
|
||||
|
||||
for k in request:
|
||||
if k == 'schedule':
|
||||
(_, data, request[k]) = self.parse_schedule(request[k], "restart")
|
||||
if _:
|
||||
status_code = _
|
||||
break
|
||||
elif k == 'role':
|
||||
if request[k] not in ('master', 'replica'):
|
||||
status_code = 400
|
||||
data = "PostgreSQL role should be either master or replica"
|
||||
break
|
||||
elif k == 'postgres_version':
|
||||
if not is_valid_pg_version(request[k]):
|
||||
status_code = 400
|
||||
data = "PostgreSQL version should be in the first.major.minor format"
|
||||
break
|
||||
elif k == 'timeout':
|
||||
request[k] = parse_int(request[k], 's')
|
||||
if request[k] is None or request[k] <= 0:
|
||||
status_code = 400
|
||||
data = "Timeout should be a positive number of seconds"
|
||||
break
|
||||
elif k != 'restart_pending':
|
||||
status_code = 400
|
||||
data = "Unknown filter for the scheduled restart: {0}".format(k)
|
||||
break
|
||||
else:
|
||||
if 'schedule' not in request:
|
||||
try:
|
||||
status, data = self.server.patroni.ha.restart(request)
|
||||
status_code = 200 if status else 503
|
||||
except Exception:
|
||||
logger.exception('Exception during restart')
|
||||
status_code = 400
|
||||
else:
|
||||
if self.server.patroni.ha.schedule_future_restart(request):
|
||||
data = "Restart scheduled"
|
||||
status_code = 202
|
||||
else:
|
||||
data = "Another restart is already scheduled"
|
||||
status_code = 409
|
||||
self._write_response(status_code, data)
|
||||
|
||||
@check_auth
|
||||
def do_DELETE_restart(self):
|
||||
if self.server.patroni.ha.delete_future_restart():
|
||||
data = "scheduled restart deleted"
|
||||
code = 200
|
||||
else:
|
||||
data = "no restarts are scheduled"
|
||||
code = 404
|
||||
self._write_response(code, data)
|
||||
|
||||
@check_auth
|
||||
def do_POST_reinitialize(self):
|
||||
ha = self.server.patroni.ha
|
||||
cluster = ha.dcs.get_cluster()
|
||||
if cluster.is_unlocked():
|
||||
status_code = 503
|
||||
data = 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'
|
||||
data = self.server.patroni.ha.reinitialize()
|
||||
if data is None:
|
||||
status_code = 200
|
||||
data = 'reinitialize started'
|
||||
else:
|
||||
action = ha.schedule_reinitialize()
|
||||
if action is not None:
|
||||
status_code = 503
|
||||
data = (action + ' already in progress').encode('utf-8')
|
||||
else:
|
||||
status_code = 200
|
||||
data = b'reinitialize scheduled'
|
||||
status_code = 503
|
||||
self._write_response(status_code, data)
|
||||
|
||||
self.send_response(status_code)
|
||||
self.send_header('Content-Type', 'text/html')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def poll_failover_result(self, leader, member):
|
||||
for a in range(0, 15):
|
||||
def poll_failover_result(self, leader, candidate):
|
||||
timeout = max(10, self.server.patroni.dcs.loop_wait)
|
||||
for _ in range(0, timeout*2):
|
||||
time.sleep(1)
|
||||
try:
|
||||
cluster = self.server.patroni.dcs.get_cluster()
|
||||
if cluster.leader and cluster.leader.name != leader:
|
||||
return 200, ('Successfully failed over to ' + cluster.leader.name).encode('utf-8')
|
||||
if not candidate or candidate == cluster.leader.name:
|
||||
return 200, 'Successfully failed over to "{0}"'.format(cluster.leader.name)
|
||||
else:
|
||||
return 200, 'Failed over to "{0}" instead of "{1}"'.format(cluster.leader.name, candidate)
|
||||
if not cluster.failover:
|
||||
return 503, b'Failover failed'
|
||||
except:
|
||||
pass
|
||||
return 503, b'Failover status unknown'
|
||||
return 503, 'Failover failed'
|
||||
except Exception as e:
|
||||
logger.debug('Exception occured during polling failover result: %s', e)
|
||||
return 503, 'Failover status unknown'
|
||||
|
||||
def is_failover_possible(self, cluster, leader, 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]
|
||||
def is_failover_possible(self, cluster, leader, candidate):
|
||||
if leader and (not cluster.leader or cluster.leader.name != leader):
|
||||
return 'leader name does not match'
|
||||
if candidate:
|
||||
members = [m for m in cluster.members if m.name == candidate]
|
||||
if not members:
|
||||
return b'member does not exists'
|
||||
return 'candidate does not exists'
|
||||
else:
|
||||
members = [m for m in cluster.members if m.name != cluster.leader.name and m.api_url]
|
||||
if not members:
|
||||
return 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):
|
||||
return 'failover is not possible: cluster does not have members except leader'
|
||||
for _, reachable, _, _, tags in self.server.patroni.ha.fetch_nodes_statuses(members):
|
||||
if reachable and not tags.get('nofailover', False):
|
||||
return None
|
||||
return b'failover is not possible: no good candidates have been found'
|
||||
return 'failover is not possible: no good candidates have been found'
|
||||
|
||||
@check_auth
|
||||
def do_POST_failover(self):
|
||||
content_length = int(self.headers.get('content-length', 0))
|
||||
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)
|
||||
request = self._read_json_content()
|
||||
status_code = 500
|
||||
if not request:
|
||||
return
|
||||
|
||||
self.send_response(status_code)
|
||||
self.send_header('Content-Type', 'text/html')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
leader = request.get('leader')
|
||||
candidate = request.get('candidate') or request.get('member')
|
||||
scheduled_at = request.get('scheduled_at')
|
||||
cluster = self.server.patroni.dcs.get_cluster()
|
||||
|
||||
if scheduled_at and cluster.is_paused():
|
||||
self._write_response(status_code, "Can't schedule failover in the paused state")
|
||||
|
||||
logger.info("received failover request with leader=%s candidate=%s scheduled_at=%s",
|
||||
leader, candidate, scheduled_at)
|
||||
|
||||
data = ''
|
||||
if leader or candidate:
|
||||
if scheduled_at:
|
||||
(_, data, scheduled_at) = self.parse_schedule(scheduled_at, "failover")
|
||||
if _:
|
||||
status_code = _
|
||||
elif self.server.patroni.dcs.manual_failover(leader, candidate, scheduled_at=scheduled_at):
|
||||
self.server.patroni.ha.wakeup()
|
||||
data = 'Failover scheduled'
|
||||
status_code = 202
|
||||
else:
|
||||
data = 'failed to write failover key into DCS'
|
||||
status_code = 503
|
||||
else:
|
||||
data = self.is_failover_possible(cluster, leader, candidate)
|
||||
if not data:
|
||||
if self.server.patroni.dcs.manual_failover(leader, candidate):
|
||||
self.server.patroni.ha.wakeup()
|
||||
status_code, data = self.poll_failover_result(cluster.leader and cluster.leader.name, candidate)
|
||||
else:
|
||||
data = 'failed to write failover key into DCS'
|
||||
status_code = 503
|
||||
else:
|
||||
status_code = 400
|
||||
data = 'No values given for required parameters leader and candidate'
|
||||
self._write_response(status_code, data)
|
||||
|
||||
def parse_request(self):
|
||||
"""Override parse_request method to enrich basic functionality of `BaseHTTPRequestHandler` class
|
||||
@@ -206,12 +366,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
self.command = mname
|
||||
return ret
|
||||
|
||||
def handle_one_request(self):
|
||||
try:
|
||||
BaseHTTPRequestHandler.handle_one_request(self)
|
||||
except socket.error:
|
||||
pass
|
||||
|
||||
def query(self, sql, *params, **kwargs):
|
||||
if not kwargs.get('retry', False):
|
||||
return self.server.query(sql, *params)
|
||||
@@ -220,59 +374,59 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def get_postgresql_status(self, retry=False):
|
||||
try:
|
||||
row = self.query("""SELECT to_char(pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),
|
||||
row = self.query("""WITH replication_info AS (
|
||||
SELECT usename, application_name, client_addr, state, sync_state, sync_priority
|
||||
FROM pg_stat_replication
|
||||
)
|
||||
SELECT to_char(pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),
|
||||
pg_is_in_recovery(),
|
||||
CASE WHEN pg_is_in_recovery()
|
||||
THEN 0
|
||||
ELSE pg_xlog_location_diff(pg_current_xlog_location(), '0/0')::bigint
|
||||
END,
|
||||
pg_xlog_location_diff(pg_last_xlog_receive_location(), '0/0')::bigint,
|
||||
pg_xlog_location_diff(COALESCE(pg_last_xlog_receive_location(),
|
||||
pg_last_xlog_replay_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 {
|
||||
to_char(pg_last_xact_replay_timestamp(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),
|
||||
pg_is_in_recovery() AND pg_is_xlog_replay_paused(),
|
||||
(SELECT array_to_json(array_agg(row_to_json(ri))) FROM replication_info ri)""",
|
||||
retry=retry)[0]
|
||||
|
||||
result = {
|
||||
'state': self.server.patroni.postgresql.state,
|
||||
'postmaster_start_time': row[0],
|
||||
'role': 'replica' if row[1] else 'master',
|
||||
'server_version': self.server.patroni.postgresql.server_version,
|
||||
'xlog': ({
|
||||
'received_location': row[3],
|
||||
'replayed_location': row[4],
|
||||
'paused': row[5]} if row[1] else {
|
||||
'replayed_timestamp': row[5],
|
||||
'paused': row[6]} if row[1] else {
|
||||
'location': row[2]
|
||||
})
|
||||
}
|
||||
|
||||
if row[7]:
|
||||
result['replication'] = row[7]
|
||||
|
||||
return result
|
||||
except (psycopg2.Error, RetryFailedError, PostgresConnectionException):
|
||||
state = self.server.patroni.postgresql.state
|
||||
if state in ['stopped', 'starting', 'stopping', 'restarting', 'running']:
|
||||
if state == 'running':
|
||||
logger.exception('get_postgresql_status')
|
||||
state = 'unknown' if state == 'running' else state
|
||||
return {'state': state}
|
||||
state = 'unknown'
|
||||
return {'state': state, 'role': self.server.patroni.postgresql.role}
|
||||
|
||||
def get_tags(self):
|
||||
return {'tags': self.server.patroni.tags}
|
||||
def log_message(self, fmt, *args):
|
||||
logger.debug("API thread: %s - - [%s] %s", self.client_address[0], self.log_date_time_string(), fmt % args)
|
||||
|
||||
|
||||
class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
|
||||
def __init__(self, patroni, config):
|
||||
self._auth_key = base64.b64encode(config['auth'].encode('utf-8')).decode('utf-8') if 'auth' in config else None
|
||||
host, port = config['listen'].split(':')
|
||||
HTTPServer.__init__(self, (host, int(port)), RestApiHandler)
|
||||
Thread.__init__(self, target=self.serve_forever)
|
||||
self._set_fd_cloexec(self.socket)
|
||||
|
||||
protocol = 'http'
|
||||
|
||||
# wrap socket with ssl if 'certfile' is defined in a config.yaml
|
||||
# Sometime it's also needed to pass reference to a 'keyfile'.
|
||||
options = {option: config[option] for option in ['certfile', 'keyfile'] if option in config}
|
||||
if options.get('certfile', 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.__initialize(config)
|
||||
self.__set_config_parameters(config)
|
||||
self.daemon = True
|
||||
|
||||
def query(self, sql, *params):
|
||||
@@ -292,11 +446,47 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
||||
fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC)
|
||||
|
||||
def check_basic_auth_key(self, key):
|
||||
return self._auth_key == key
|
||||
return self.__auth_key == key
|
||||
|
||||
def check_auth_header(self, auth_header):
|
||||
if self._auth_key:
|
||||
if self.__auth_key:
|
||||
if auth_header is None:
|
||||
return 'no auth header received'
|
||||
if not auth_header.startswith('Basic ') or not self.check_basic_auth_key(auth_header[6:]):
|
||||
return 'not authenticated'
|
||||
|
||||
@staticmethod
|
||||
def __get_ssl_options(config):
|
||||
return {option: config[option] for option in ['certfile', 'keyfile'] if option in config}
|
||||
|
||||
def __set_connection_string(self, connect_address):
|
||||
self.connection_string = '{0}://{1}/patroni'.format(self.__protocol, connect_address or self.__listen)
|
||||
|
||||
def __set_config_parameters(self, config):
|
||||
self.__auth_key = base64.b64encode(config['auth'].encode('utf-8')).decode('utf-8') if 'auth' in config else None
|
||||
self.__set_connection_string(config.get('connect_address'))
|
||||
|
||||
def __initialize(self, config):
|
||||
self.__ssl_options = self.__get_ssl_options(config)
|
||||
self.__listen = config['listen']
|
||||
host, port = config['listen'].split(':')
|
||||
HTTPServer.__init__(self, (host, int(port)), RestApiHandler)
|
||||
Thread.__init__(self, target=self.serve_forever)
|
||||
self._set_fd_cloexec(self.socket)
|
||||
|
||||
self.__protocol = 'http'
|
||||
|
||||
# wrap socket with ssl if 'certfile' is defined in a config.yaml
|
||||
# Sometime it's also needed to pass reference to a 'keyfile'.
|
||||
if self.__ssl_options.get('certfile'):
|
||||
import ssl
|
||||
self.socket = ssl.wrap_socket(self.socket, server_side=True, **self.__ssl_options)
|
||||
self.__protocol = 'https'
|
||||
self.__set_connection_string(config.get('connect_address'))
|
||||
|
||||
def reload_config(self, config):
|
||||
self.__set_config_parameters(config)
|
||||
if self.__listen != config['listen'] or self.__ssl_options != self.__get_ssl_options(config):
|
||||
self.shutdown()
|
||||
self.__initialize(config)
|
||||
self.start()
|
||||
|
||||
+14
-13
@@ -1,28 +1,26 @@
|
||||
import logging
|
||||
from threading import Lock, Thread
|
||||
from threading import RLock, Thread
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncExecutor:
|
||||
class AsyncExecutor(object):
|
||||
|
||||
def __init__(self):
|
||||
Lock.__init__(self)
|
||||
self._busy = False
|
||||
self._thread_lock = Lock()
|
||||
def __init__(self, ha_wakeup):
|
||||
self._ha_wakeup = ha_wakeup
|
||||
self._thread_lock = RLock()
|
||||
self._scheduled_action = None
|
||||
self._scheduled_action_lock = Lock()
|
||||
self._scheduled_action_lock = RLock()
|
||||
|
||||
@property
|
||||
def busy(self):
|
||||
return self._busy
|
||||
return self.scheduled_action is not None
|
||||
|
||||
def schedule(self, action, immediately=False):
|
||||
with self._scheduled_action_lock:
|
||||
if self._scheduled_action is not None:
|
||||
return self._scheduled_action
|
||||
self._scheduled_action = action
|
||||
self._busy = immediately
|
||||
return None
|
||||
|
||||
@property
|
||||
@@ -35,21 +33,24 @@ class AsyncExecutor:
|
||||
self._scheduled_action = None
|
||||
|
||||
def run(self, func, args=()):
|
||||
wakeup = False
|
||||
try:
|
||||
return func(*args) if args else func()
|
||||
# if the func returned something (not None) - wake up main HA loop
|
||||
wakeup = func(*args) if args else func()
|
||||
return wakeup
|
||||
except:
|
||||
logger.exception('Exception during execution of long running task %s', self.scheduled_action)
|
||||
finally:
|
||||
with self:
|
||||
self._busy = False
|
||||
self.reset_scheduled_action()
|
||||
if wakeup is not None:
|
||||
self._ha_wakeup()
|
||||
|
||||
def run_async(self, func, args=()):
|
||||
self._busy = True
|
||||
Thread(target=self.run, args=(func, args)).start()
|
||||
|
||||
def __enter__(self):
|
||||
self._thread_lock.acquire()
|
||||
|
||||
def __exit__(self, type, value, traceback):
|
||||
def __exit__(self, *args):
|
||||
self._thread_lock.release()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import logging
|
||||
import subprocess
|
||||
from threading import Event, Lock, Thread
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CallbackExecutor(Thread):
|
||||
|
||||
def __init__(self):
|
||||
super(CallbackExecutor, self).__init__()
|
||||
self.daemon = True
|
||||
self._lock = Lock()
|
||||
self._cmd = None
|
||||
self._process = None
|
||||
self._callback_event = Event()
|
||||
self.start()
|
||||
|
||||
def call(self, cmd):
|
||||
with self._lock:
|
||||
if self._process and self._process.poll() is None:
|
||||
self._process.kill()
|
||||
logger.warning('Killed the old callback process because it was still running: %s', self._cmd)
|
||||
self._cmd = cmd
|
||||
self._callback_event.set()
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
self._callback_event.wait()
|
||||
self._callback_event.clear()
|
||||
with self._lock:
|
||||
try:
|
||||
self._process = subprocess.Popen(self._cmd, close_fds=True)
|
||||
except Exception:
|
||||
logger.exception('Failed to execute %s', self._cmd)
|
||||
continue
|
||||
self._process.wait()
|
||||
@@ -0,0 +1,316 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import yaml
|
||||
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from patroni.dcs import ClusterConfig
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.utils import deep_compare, parse_int, patch_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Config(object):
|
||||
"""
|
||||
This class is responsible for:
|
||||
|
||||
1) Building and giving access to `effective_configuration` from:
|
||||
* `Config.__DEFAULT_CONFIG` -- some sane default values
|
||||
* `dynamic_configuration` -- configuration stored in DCS
|
||||
* `local_configuration` -- configuration from `config.yml` or environment
|
||||
|
||||
2) Saving and loading `dynamic_configuration` into 'patroni.dynamic.json' file
|
||||
located in local_configuration['postgresql']['data_dir'] directory.
|
||||
This is necessary to be able to restore `dynamic_configuration`
|
||||
if DCS was accidentally wiped
|
||||
|
||||
3) Loading of configuration file in the old format and converting it into new format
|
||||
|
||||
4) Mimicking some of the `dict` interfaces to make it possible
|
||||
to work with it as with the old `config` object.
|
||||
"""
|
||||
|
||||
PATRONI_ENV_PREFIX = 'PATRONI_'
|
||||
PATRONI_CONFIG_VARIABLE = PATRONI_ENV_PREFIX + 'CONFIGURATION'
|
||||
|
||||
__CACHE_FILENAME = 'patroni.dynamic.json'
|
||||
__DEFAULT_CONFIG = {
|
||||
'ttl': 30, 'loop_wait': 10, 'retry_timeout': 10,
|
||||
'maximum_lag_on_failover': 1048576,
|
||||
'master_start_timeout': 300,
|
||||
'synchronous_mode': False,
|
||||
'postgresql': {
|
||||
'bin_dir': '',
|
||||
'use_slots': True,
|
||||
'parameters': {p: v[0] for p, v in Postgresql.CMDLINE_OPTIONS.items()}
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self._modify_index = -1
|
||||
self._dynamic_configuration = {}
|
||||
|
||||
self.__environment_configuration = self._build_environment_configuration()
|
||||
|
||||
# Patroni reads the configuration from the command-line argument if it exists, otherwise from the environment
|
||||
self._config_file = len(sys.argv) >= 2 and os.path.isfile(sys.argv[1]) and sys.argv[1]
|
||||
if self._config_file:
|
||||
self._local_configuration = self._load_config_file()
|
||||
else:
|
||||
config_env = os.environ.pop(self.PATRONI_CONFIG_VARIABLE, None)
|
||||
self._local_configuration = config_env and yaml.safe_load(config_env) or self.__environment_configuration
|
||||
if not self._local_configuration:
|
||||
print('Usage: {0} config.yml'.format(sys.argv[0]))
|
||||
print('\tPatroni may also read the configuration from the {0} environment variable'.
|
||||
format(self.PATRONI_CONFIG_VARIABLE))
|
||||
sys.exit(1)
|
||||
|
||||
self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration)
|
||||
self._data_dir = self.__effective_configuration['postgresql']['data_dir']
|
||||
self._cache_file = os.path.join(self._data_dir, self.__CACHE_FILENAME)
|
||||
self._load_cache()
|
||||
self._cache_needs_saving = False
|
||||
|
||||
@property
|
||||
def config_file(self):
|
||||
return self._config_file
|
||||
|
||||
@property
|
||||
def dynamic_configuration(self):
|
||||
return deepcopy(self._dynamic_configuration)
|
||||
|
||||
def _load_config_file(self):
|
||||
"""Loads config.yaml from filesystem and applies some values which were set via ENV"""
|
||||
with open(self._config_file) as f:
|
||||
config = yaml.safe_load(f)
|
||||
patch_config(config, self.__environment_configuration)
|
||||
return config
|
||||
|
||||
def _load_cache(self):
|
||||
if os.path.isfile(self._cache_file):
|
||||
try:
|
||||
with open(self._cache_file) as f:
|
||||
self.set_dynamic_configuration(json.load(f))
|
||||
except Exception:
|
||||
logger.exception('Exception when loading file: %s', self._cache_file)
|
||||
|
||||
def save_cache(self):
|
||||
if self._cache_needs_saving:
|
||||
tmpfile = fd = None
|
||||
try:
|
||||
(fd, tmpfile) = tempfile.mkstemp(prefix=self.__CACHE_FILENAME, dir=self._data_dir)
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
fd = None
|
||||
json.dump(self.dynamic_configuration, f)
|
||||
tmpfile = os.rename(tmpfile, self._cache_file)
|
||||
self._cache_needs_saving = False
|
||||
except Exception:
|
||||
logger.exception('Exception when saving file: %s', self._cache_file)
|
||||
if fd:
|
||||
try:
|
||||
os.close(fd)
|
||||
except Exception:
|
||||
logger.error('Can not close temporary file %s', tmpfile)
|
||||
if tmpfile and os.path.exists(tmpfile):
|
||||
try:
|
||||
os.remove(tmpfile)
|
||||
except Exception:
|
||||
logger.error('Can not remove temporary file %s', tmpfile)
|
||||
|
||||
# configuration could be either ClusterConfig or dict
|
||||
def set_dynamic_configuration(self, configuration):
|
||||
if isinstance(configuration, ClusterConfig):
|
||||
if self._modify_index == configuration.modify_index:
|
||||
return False # If the index didn't changed there is nothing to do
|
||||
self._modify_index = configuration.modify_index
|
||||
configuration = configuration.data
|
||||
|
||||
if not deep_compare(self._dynamic_configuration, configuration):
|
||||
try:
|
||||
self.__effective_configuration = self._build_effective_configuration(configuration,
|
||||
self._local_configuration)
|
||||
self._dynamic_configuration = configuration
|
||||
self._cache_needs_saving = True
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception('Exception when setting dynamic_configuration')
|
||||
|
||||
def reload_local_configuration(self, dry_run=False):
|
||||
if self.config_file:
|
||||
try:
|
||||
configuration = self._load_config_file()
|
||||
if not deep_compare(self._local_configuration, configuration):
|
||||
new_configuration = self._build_effective_configuration(self._dynamic_configuration, configuration)
|
||||
if dry_run:
|
||||
return not deep_compare(new_configuration, self.__effective_configuration)
|
||||
self._local_configuration = configuration
|
||||
self.__effective_configuration = new_configuration
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception('Exception when reloading local configuration from %s', self.config_file)
|
||||
if dry_run:
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _process_postgresql_parameters(parameters, is_local=False):
|
||||
ret = {}
|
||||
for name, value in (parameters or {}).items():
|
||||
if name not in Postgresql.CMDLINE_OPTIONS or not is_local and Postgresql.CMDLINE_OPTIONS[name][1](value):
|
||||
ret[name] = value
|
||||
return ret
|
||||
|
||||
def _safe_copy_dynamic_configuration(self, dynamic_configuration):
|
||||
config = deepcopy(self.__DEFAULT_CONFIG)
|
||||
|
||||
for name, value in dynamic_configuration.items():
|
||||
if name == 'postgresql':
|
||||
for name, value in (value or {}).items():
|
||||
if name == 'parameters':
|
||||
config['postgresql'][name].update(self._process_postgresql_parameters(value))
|
||||
elif name not in ('connect_address', 'listen', 'data_dir', 'pgpass', 'authentication'):
|
||||
config['postgresql'][name] = deepcopy(value)
|
||||
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overriden from DCS
|
||||
if name == 'synchronous_mode':
|
||||
config[name] = value
|
||||
else:
|
||||
config[name] = int(value)
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def _build_environment_configuration():
|
||||
ret = defaultdict(dict)
|
||||
|
||||
def _popenv(name):
|
||||
return os.environ.pop(Config.PATRONI_ENV_PREFIX + name.upper(), None)
|
||||
|
||||
for param in ('name', 'namespace', 'scope'):
|
||||
value = _popenv(param)
|
||||
if value:
|
||||
ret[param] = value
|
||||
|
||||
def _set_section_values(section, params):
|
||||
for param in params:
|
||||
value = _popenv(section + '_' + param)
|
||||
if value:
|
||||
ret[section][param] = value
|
||||
|
||||
_set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile'])
|
||||
_set_section_values('postgresql', ['listen', 'connect_address', 'data_dir', 'pgpass', 'bin_dir'])
|
||||
|
||||
def _get_auth(name):
|
||||
ret = {}
|
||||
for param in ('username', 'password'):
|
||||
value = _popenv(name + '_' + param)
|
||||
if value:
|
||||
ret[param] = value
|
||||
return ret
|
||||
|
||||
restapi_auth = _get_auth('restapi')
|
||||
if restapi_auth:
|
||||
ret['restapi']['authentication'] = restapi_auth
|
||||
|
||||
authentication = {}
|
||||
for user_type in ('replication', 'superuser'):
|
||||
entry = _get_auth(user_type)
|
||||
if entry:
|
||||
authentication[user_type] = entry
|
||||
|
||||
if authentication:
|
||||
ret['postgresql']['authentication'] = authentication
|
||||
|
||||
users = {}
|
||||
|
||||
def _parse_list(value):
|
||||
if not (value.strip().startswith('-') or '[' in value):
|
||||
value = '[{0}]'.format(value)
|
||||
try:
|
||||
return yaml.safe_load(value)
|
||||
except Exception:
|
||||
logger.exception('Exception when parsing list %s', value)
|
||||
return None
|
||||
|
||||
for param in list(os.environ.keys()):
|
||||
if param.startswith(Config.PATRONI_ENV_PREFIX):
|
||||
name, suffix = (param[8:].rsplit('_', 1) + [''])[:2]
|
||||
if name and suffix:
|
||||
# PATRONI_(ETCD|CONSUL|ZOOKEEPER|EXHIBITOR|...)_(HOSTS?|PORT|..)
|
||||
if suffix in ('HOST', 'HOSTS', 'PORT', 'SRV', 'URL', 'PROXY', 'CACERT', 'CERT', 'KEY') \
|
||||
and '_' not in name:
|
||||
value = os.environ.pop(param)
|
||||
if suffix == 'PORT':
|
||||
value = value and parse_int(value)
|
||||
elif suffix == 'HOSTS':
|
||||
value = value and _parse_list(value)
|
||||
if value:
|
||||
ret[name.lower()][suffix.lower()] = value
|
||||
# PATRONI_<username>_PASSWORD=<password>, PATRONI_<username>_OPTIONS=<option1,option2,...>
|
||||
# CREATE USER "<username>" WITH <OPTIONS> PASSWORD '<password>'
|
||||
elif suffix == 'PASSWORD':
|
||||
password = os.environ.pop(param)
|
||||
if password:
|
||||
users[name] = {'password': password}
|
||||
options = os.environ.pop(param[:-9] + '_OPTIONS', None)
|
||||
options = options and _parse_list(options)
|
||||
if options:
|
||||
users[name]['options'] = options
|
||||
if users:
|
||||
ret['bootstrap']['users'] = users
|
||||
|
||||
return ret
|
||||
|
||||
def _build_effective_configuration(self, dynamic_configuration, local_configuration):
|
||||
config = self._safe_copy_dynamic_configuration(dynamic_configuration)
|
||||
for name, value in local_configuration.items():
|
||||
if name == 'postgresql':
|
||||
for name, value in (value or {}).items():
|
||||
if name == 'parameters':
|
||||
config['postgresql'][name].update(self._process_postgresql_parameters(value, True))
|
||||
elif name != 'use_slots': # replication slots must be enabled/disabled globally
|
||||
config['postgresql'][name] = deepcopy(value)
|
||||
elif name not in config:
|
||||
config[name] = deepcopy(value) if value else {}
|
||||
|
||||
# restapi server expects to get restapi.auth = 'username:password'
|
||||
if 'authentication' in config['restapi']:
|
||||
config['restapi']['auth'] = '{username}:{password}'.format(**config['restapi']['authentication'])
|
||||
|
||||
# special treatment for old config
|
||||
|
||||
# 'exhibitor' inside 'zookeeper':
|
||||
if 'zookeeper' in config and 'exhibitor' in config['zookeeper']:
|
||||
config['exhibitor'] = config['zookeeper'].pop('exhibitor')
|
||||
config.pop('zookeeper')
|
||||
|
||||
pg_config = config['postgresql']
|
||||
# no 'authentication' in 'postgresql', but 'replication' and 'superuser'
|
||||
if 'authentication' not in pg_config:
|
||||
pg_config['use_pg_rewind'] = 'pg_rewind' in pg_config
|
||||
pg_config['authentication'] = {u: pg_config[u] for u in ('replication', 'superuser') if u in pg_config}
|
||||
# no 'superuser' in 'postgresql'.'authentication'
|
||||
if 'superuser' not in pg_config['authentication'] and 'pg_rewind' in pg_config:
|
||||
pg_config['authentication']['superuser'] = pg_config['pg_rewind']
|
||||
|
||||
# no 'name' in config
|
||||
if 'name' not in config and 'name' in pg_config:
|
||||
config['name'] = pg_config['name']
|
||||
|
||||
pg_config.update({p: config[p] for p in ('name', 'scope', 'retry_timeout',
|
||||
'synchronous_mode', 'maximum_lag_on_failover') if p in config})
|
||||
|
||||
return config
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self.__effective_configuration.get(key, default)
|
||||
|
||||
def __contains__(self, key):
|
||||
return key in self.__effective_configuration
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.__effective_configuration[key]
|
||||
|
||||
def copy(self):
|
||||
return deepcopy(self.__effective_configuration)
|
||||
+457
-249
File diff suppressed because it is too large
Load Diff
-274
@@ -1,274 +0,0 @@
|
||||
import abc
|
||||
import json
|
||||
|
||||
from collections import namedtuple
|
||||
from patroni.exceptions import DCSError
|
||||
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
|
||||
from threading import Event, Lock
|
||||
|
||||
|
||||
def parse_connection_string(value):
|
||||
"""Original Governor stores connection strings for each cluster members if a following format:
|
||||
postgres://{username}:{password}@{connect_address}/postgres
|
||||
Since each of our patroni instances provides own REST API endpoint it's good to store this information
|
||||
in DCS among with postgresql connection string. In order to not introduce new keys and be compatible with
|
||||
original Governor we decided to extend original connection string in a following way:
|
||||
postgres://{username}:{password}@{connect_address}/postgres?application_name={api_url}
|
||||
This way original Governor could use such connection string as it is, because of feature of `libpq` library.
|
||||
|
||||
This method is able to split connection string stored in DCS into two parts, `conn_url` and `api_url`"""
|
||||
|
||||
scheme, netloc, path, params, query, fragment = urlparse(value)
|
||||
conn_url = urlunparse((scheme, netloc, path, params, '', fragment))
|
||||
api_url = ([v for n, v in parse_qsl(query) if n == 'application_name'] or [None])[0]
|
||||
return conn_url, api_url
|
||||
|
||||
|
||||
class Member(namedtuple('Member', 'index,name,session,data')):
|
||||
|
||||
"""Immutable object (namedtuple) which represents single member of PostgreSQL cluster.
|
||||
Consists of the following fields:
|
||||
:param index: modification index of a given member key in a Configuration Store
|
||||
:param name: name of PostgreSQL cluster member
|
||||
:param session: either session id or just ttl in seconds
|
||||
:param data: arbitrary data i.e. conn_url, api_url, xlog location, state, role, tags, etc...
|
||||
|
||||
There are two mandatory keys in a data:
|
||||
conn_url: connection string containing host, user and password which could be used to access this member.
|
||||
api_url: REST API url of patroni instance"""
|
||||
|
||||
@staticmethod
|
||||
def from_node(index, name, session, data):
|
||||
"""
|
||||
>>> Member.from_node(-1, '', '', '{"conn_url": "postgres://foo@bar/postgres"}') is not None
|
||||
True
|
||||
>>> Member.from_node(-1, '', '', '{')
|
||||
Member(index=-1, name='', session='', data={})
|
||||
"""
|
||||
if data.startswith('postgres'):
|
||||
conn_url, api_url = parse_connection_string(data)
|
||||
data = {'conn_url': conn_url, 'api_url': api_url}
|
||||
else:
|
||||
try:
|
||||
data = json.loads(data)
|
||||
except:
|
||||
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,session,member')):
|
||||
|
||||
"""Immutable object (namedtuple) which represents leader key.
|
||||
Consists of the following fields:
|
||||
:param index: modification index of a leader key in a Configuration Store
|
||||
:param session: either session id or just ttl in seconds
|
||||
:param member: reference to a `Member` object which represents current leader (see `Cluster.members`)"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.member.name
|
||||
|
||||
@property
|
||||
def conn_url(self):
|
||||
return self.member.conn_url
|
||||
|
||||
|
||||
class Failover(namedtuple('Failover', 'index,leader,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.
|
||||
Consists of the following fields:
|
||||
:param initialize: boolean, shows whether this cluster has initialization key stored in DC or not.
|
||||
:param leader: `Leader` object which represents current leader of the cluster
|
||||
:param last_leader_operation: int or long object containing position of last known leader operation.
|
||||
This value is stored in `/optime/leader` key
|
||||
:param members: list of Member object, all PostgreSQL cluster members including leader
|
||||
:param failover: reference to `Failover` object"""
|
||||
|
||||
def is_unlocked(self):
|
||||
return not (self.leader and self.leader.name)
|
||||
|
||||
|
||||
class AbstractDCS:
|
||||
|
||||
__metaclass__ = abc.ABCMeta
|
||||
|
||||
_INITIALIZE = 'initialize'
|
||||
_LEADER = 'leader'
|
||||
_FAILOVER = 'failover'
|
||||
_MEMBERS = 'members/'
|
||||
_OPTIME = 'optime'
|
||||
_LEADER_OPTIME = _OPTIME + '/' + _LEADER
|
||||
|
||||
def __init__(self, name, config):
|
||||
"""
|
||||
:param name: name of current instance (the same value as `~Postgresql.name`)
|
||||
:param config: dict, reference to config section of selected DCS.
|
||||
i.e.: `zookeeper` for zookeeper, `etcd` for etcd, etc...
|
||||
"""
|
||||
self._name = name
|
||||
self._namespace = '/{}'.format(config.get('namespace', '/service/').strip('/'))
|
||||
self._base_path = '/'.join([self._namespace, config['scope']])
|
||||
|
||||
self._cluster = None
|
||||
self._cluster_thread_lock = Lock()
|
||||
self.event = Event()
|
||||
|
||||
def client_path(self, path):
|
||||
return '/'.join([self._base_path, path.lstrip('/')])
|
||||
|
||||
@property
|
||||
def initialize_path(self):
|
||||
return self.client_path(self._INITIALIZE)
|
||||
|
||||
@property
|
||||
def members_path(self):
|
||||
return self.client_path(self._MEMBERS)
|
||||
|
||||
@property
|
||||
def member_path(self):
|
||||
return self.client_path(self._MEMBERS + self._name)
|
||||
|
||||
@property
|
||||
def leader_path(self):
|
||||
return self.client_path(self._LEADER)
|
||||
|
||||
@property
|
||||
def failover_path(self):
|
||||
return self.client_path(self._FAILOVER)
|
||||
|
||||
@property
|
||||
def leader_optime_path(self):
|
||||
return self.client_path(self._LEADER_OPTIME)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _load_cluster(self):
|
||||
"""Internally this method should build `Cluster` object which
|
||||
represents current state and topology of the cluster in DCS.
|
||||
this method supposed to be called only by `get_cluster` method.
|
||||
|
||||
raise `~DCSError` in case of communication or other problems with DCS.
|
||||
If the current node was running as a master and exception raised,
|
||||
instance would be demoted."""
|
||||
|
||||
def get_cluster(self):
|
||||
with self._cluster_thread_lock:
|
||||
try:
|
||||
self._load_cluster()
|
||||
except:
|
||||
self._cluster = None
|
||||
raise
|
||||
return self._cluster
|
||||
|
||||
@property
|
||||
def cluster(self):
|
||||
with self._cluster_thread_lock:
|
||||
return self._cluster
|
||||
|
||||
def reset_cluster(self):
|
||||
with self._cluster_thread_lock:
|
||||
self._cluster = None
|
||||
|
||||
@abc.abstractmethod
|
||||
def write_leader_optime(self, last_operation):
|
||||
"""write current xlog location into `/optime/leader` key in DCS
|
||||
:param last_operation: absolute xlog location in bytes"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def update_leader(self):
|
||||
"""Update leader key (or session) ttl
|
||||
|
||||
:returns: `!True` if leader key (or session) has been updated successfully.
|
||||
If not, `!False` must be returned and current instance would be demoted.
|
||||
|
||||
You have to use CAS (Compare And Swap) operation in order to update leader key,
|
||||
for example for etcd `prevValue` parameter must be used."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def attempt_to_acquire_leader(self):
|
||||
"""Attempt to acquire leader lock
|
||||
This method should create `/leader` key with value=`~self._name`
|
||||
:returns: `!True` if key has been created successfully.
|
||||
|
||||
Key must be created atomically. In case if key already exists it should not be
|
||||
overwritten and `!False` must be returned"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_failover_value(self, value, index=None):
|
||||
"""Create or update `/failover` key"""
|
||||
|
||||
def manual_failover(self, leader, member, index=None):
|
||||
return self.set_failover_value(leader + (':' + member if member else ''), index)
|
||||
|
||||
def current_leader(self):
|
||||
try:
|
||||
cluster = self.get_cluster()
|
||||
return None if cluster.is_unlocked() else cluster.leader
|
||||
except DCSError:
|
||||
return None
|
||||
|
||||
@abc.abstractmethod
|
||||
def touch_member(self, connection_string, ttl=None):
|
||||
"""Update member key in DCS.
|
||||
This method should create or update key with the name = '/members/' + `~self._name`
|
||||
and value = connection_string in a given DCS.
|
||||
|
||||
:param connection_string: how this instance can be accessed by other instances
|
||||
:param ttl: ttl for member key, optional parameter. If it is None `~self.member_ttl will be used`
|
||||
:returns: `!True` on success otherwise `!False`
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def take_leader(self):
|
||||
"""This method should create leader key with value = `~self._name` and ttl=`~self.ttl`
|
||||
Since it could be called only on initial cluster bootstrap it could create this key regardless,
|
||||
overwriting the key if necessary."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def initialize(self, create_new=True, sysid=""):
|
||||
"""Race for cluster initialization.
|
||||
|
||||
:param create_new: False if the key should already exist (in the case we are setting the system_id)
|
||||
:param sysid: PostgreSQL cluster system identifier, if specified, is written to the key
|
||||
:returns: `!True` if key has been created successfully.
|
||||
|
||||
this method should create atomically initialize key and return `!True`
|
||||
otherwise it should return `!False`"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def delete_leader(self):
|
||||
"""Voluntarily remove leader key from DCS
|
||||
This method should remove leader key if current instance is the leader"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def cancel_initialization(self):
|
||||
""" Removes the initialize key for a cluster """
|
||||
|
||||
def watch(self, timeout):
|
||||
"""If the current node is a master it should just sleep.
|
||||
Any other node should watch for changes of leader key with a given timeout
|
||||
|
||||
:param timeout: timeout in seconds
|
||||
:returns: `!True` if you would like to reschedule the next run of ha cycle"""
|
||||
|
||||
self.event.wait(timeout)
|
||||
return self.event.isSet()
|
||||
@@ -0,0 +1,543 @@
|
||||
import abc
|
||||
import dateutil
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import pkgutil
|
||||
import six
|
||||
import sys
|
||||
|
||||
from collections import namedtuple
|
||||
from patroni.exceptions import PatroniException
|
||||
from random import randint
|
||||
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
|
||||
from threading import Event, Lock
|
||||
|
||||
|
||||
def parse_connection_string(value):
|
||||
"""Original Governor stores connection strings for each cluster members if a following format:
|
||||
postgres://{username}:{password}@{connect_address}/postgres
|
||||
Since each of our patroni instances provides own REST API endpoint it's good to store this information
|
||||
in DCS among with postgresql connection string. In order to not introduce new keys and be compatible with
|
||||
original Governor we decided to extend original connection string in a following way:
|
||||
postgres://{username}:{password}@{connect_address}/postgres?application_name={api_url}
|
||||
This way original Governor could use such connection string as it is, because of feature of `libpq` library.
|
||||
|
||||
This method is able to split connection string stored in DCS into two parts, `conn_url` and `api_url`"""
|
||||
|
||||
scheme, netloc, path, params, query, fragment = urlparse(value)
|
||||
conn_url = urlunparse((scheme, netloc, path, params, '', fragment))
|
||||
api_url = ([v for n, v in parse_qsl(query) if n == 'application_name'] or [None])[0]
|
||||
return conn_url, api_url
|
||||
|
||||
|
||||
def dcs_modules():
|
||||
"""Get names of DCS modules, depending on execution environment. If being packaged with PyInstaller,
|
||||
modules aren't discoverable dynamically by scanning source directory because `FrozenImporter` doesn't
|
||||
implement `iter_modules` method. But it is still possible to find all potential DCS modules by
|
||||
iterating through `toc`, which contains list of all "frozen" resources."""
|
||||
|
||||
dcs_dirname = os.path.dirname(__file__)
|
||||
module_prefix = __package__ + '.'
|
||||
|
||||
if getattr(sys, 'frozen', False):
|
||||
importer = pkgutil.get_importer(dcs_dirname)
|
||||
return [module for module in list(importer.toc) if module.startswith(module_prefix) and module.count('.') == 2]
|
||||
else:
|
||||
return [module_prefix + name for _, name, is_pkg in pkgutil.iter_modules([dcs_dirname]) if not is_pkg]
|
||||
|
||||
|
||||
def get_dcs(config):
|
||||
available_implementations = set()
|
||||
for module_name in dcs_modules():
|
||||
module = importlib.import_module(module_name)
|
||||
for name in filter(lambda name: not name.startswith('__'), dir(module)): # iterate through module content
|
||||
value = getattr(module, name)
|
||||
name = name.lower()
|
||||
# try to find implementation of AbstractDCS interface, class name must match with module_name
|
||||
if inspect.isclass(value) and issubclass(value, AbstractDCS) and __package__ + '.' + name == module_name:
|
||||
available_implementations.add(name)
|
||||
if name in config: # which has configuration section in the config file
|
||||
# propagate some parameters
|
||||
config[name].update({p: config[p] for p in ('namespace', 'name', 'scope', 'loop_wait',
|
||||
'patronictl', 'ttl', 'retry_timeout') if p in config})
|
||||
return value(config[name])
|
||||
raise PatroniException("""Can not find suitable configuration of distributed configuration store
|
||||
Available implementations: """ + ', '.join(available_implementations))
|
||||
|
||||
|
||||
class Member(namedtuple('Member', 'index,name,session,data')):
|
||||
|
||||
"""Immutable object (namedtuple) which represents single member of PostgreSQL cluster.
|
||||
Consists of the following fields:
|
||||
:param index: modification index of a given member key in a Configuration Store
|
||||
:param name: name of PostgreSQL cluster member
|
||||
:param session: either session id or just ttl in seconds
|
||||
:param data: arbitrary data i.e. conn_url, api_url, xlog location, state, role, tags, etc...
|
||||
|
||||
There are two mandatory keys in a data:
|
||||
conn_url: connection string containing host, user and password which could be used to access this member.
|
||||
api_url: REST API url of patroni instance"""
|
||||
|
||||
@staticmethod
|
||||
def from_node(index, name, session, data):
|
||||
"""
|
||||
>>> Member.from_node(-1, '', '', '{"conn_url": "postgres://foo@bar/postgres"}') is not None
|
||||
True
|
||||
>>> Member.from_node(-1, '', '', '{')
|
||||
Member(index=-1, name='', session='', data={})
|
||||
"""
|
||||
if data.startswith('postgres'):
|
||||
conn_url, api_url = parse_connection_string(data)
|
||||
data = {'conn_url': conn_url, 'api_url': api_url}
|
||||
else:
|
||||
try:
|
||||
data = json.loads(data)
|
||||
except (TypeError, ValueError):
|
||||
data = {}
|
||||
return Member(index, name, session, data)
|
||||
|
||||
@property
|
||||
def conn_url(self):
|
||||
return self.data.get('conn_url')
|
||||
|
||||
def conn_kwargs(self, auth=None):
|
||||
ret = self.data.get('conn_kwargs')
|
||||
if ret:
|
||||
ret = ret.copy()
|
||||
else:
|
||||
r = urlparse(self.conn_url)
|
||||
ret = {
|
||||
'host': r.hostname,
|
||||
'port': r.port or 5432,
|
||||
'database': r.path[1:]
|
||||
}
|
||||
self.data['conn_kwargs'] = ret.copy()
|
||||
|
||||
if auth and isinstance(auth, dict):
|
||||
if 'username' in auth:
|
||||
ret['user'] = auth['username']
|
||||
if 'password' in auth:
|
||||
ret['password'] = auth['password']
|
||||
return ret
|
||||
|
||||
@property
|
||||
def api_url(self):
|
||||
return self.data.get('api_url')
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
return self.data.get('tags', {})
|
||||
|
||||
@property
|
||||
def nofailover(self):
|
||||
return self.tags.get('nofailover', False)
|
||||
|
||||
@property
|
||||
def replicatefrom(self):
|
||||
return self.tags.get('replicatefrom')
|
||||
|
||||
@property
|
||||
def clonefrom(self):
|
||||
return self.tags.get('clonefrom', False) and bool(self.conn_url)
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
return self.data.get('state', 'unknown')
|
||||
|
||||
@property
|
||||
def is_running(self):
|
||||
return self.state == 'running'
|
||||
|
||||
|
||||
class Leader(namedtuple('Leader', 'index,session,member')):
|
||||
|
||||
"""Immutable object (namedtuple) which represents leader key.
|
||||
Consists of the following fields:
|
||||
:param index: modification index of a leader key in a Configuration Store
|
||||
:param session: either session id or just ttl in seconds
|
||||
:param member: reference to a `Member` object which represents current leader (see `Cluster.members`)"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.member.name
|
||||
|
||||
def conn_kwargs(self, auth=None):
|
||||
return self.member.conn_kwargs(auth)
|
||||
|
||||
@property
|
||||
def conn_url(self):
|
||||
return self.member.conn_url
|
||||
|
||||
|
||||
class Failover(namedtuple('Failover', 'index,leader,candidate,scheduled_at')):
|
||||
|
||||
"""
|
||||
>>> 'Failover' in str(Failover.from_node(1, '{"leader": "cluster_leader"}'))
|
||||
True
|
||||
>>> 'Failover' in str(Failover.from_node(1, '{"leader": "cluster_leader", "member": "cluster_candidate"}'))
|
||||
True
|
||||
>>> Failover.from_node(1, 'null') is None
|
||||
True
|
||||
>>> n = '{"leader": "cluster_leader", "member": "cluster_candidate", "scheduled_at": "2016-01-14T10:09:57.1394Z"}'
|
||||
>>> 'tzinfo=' in str(Failover.from_node(1, n))
|
||||
True
|
||||
>>> Failover.from_node(1, None) is None
|
||||
True
|
||||
>>> Failover.from_node(1, '{}') is None
|
||||
True
|
||||
>>> 'abc' in Failover.from_node(1, 'abc:def')
|
||||
True
|
||||
"""
|
||||
@staticmethod
|
||||
def from_node(index, value):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(value)
|
||||
if not data:
|
||||
return None
|
||||
except ValueError:
|
||||
t = [a.strip() for a in value.split(':')]
|
||||
leader = t[0]
|
||||
candidate = t[1] if len(t) > 1 else None
|
||||
return Failover(index, leader, candidate, None) if leader or candidate else None
|
||||
|
||||
if data.get('scheduled_at'):
|
||||
data['scheduled_at'] = dateutil.parser.parse(data['scheduled_at'])
|
||||
|
||||
return Failover(index, data.get('leader'), data.get('member'), data.get('scheduled_at'))
|
||||
|
||||
def __len__(self):
|
||||
return int(bool(self.leader)) + int(bool(self.candidate))
|
||||
|
||||
|
||||
class ClusterConfig(namedtuple('ClusterConfig', 'index,data,modify_index')):
|
||||
|
||||
@staticmethod
|
||||
def from_node(index, data, modify_index=None):
|
||||
"""
|
||||
>>> ClusterConfig.from_node(1, '{') is None
|
||||
True
|
||||
"""
|
||||
|
||||
try:
|
||||
data = json.loads(data)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return ClusterConfig(index, data, modify_index or index)
|
||||
|
||||
|
||||
class SyncState(namedtuple('SyncState', 'index,leader,sync_standby')):
|
||||
"""Immutable object (namedtuple) which represents last observed synhcronous replication state
|
||||
|
||||
:param index: modification index of a synchronization key in a Configuration Store
|
||||
:param leader: reference to member that was leader
|
||||
:param sync_standby: standby that was last synchronized to leader
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def from_node(index, value):
|
||||
"""
|
||||
>>> SyncState.from_node(1, None).leader is None
|
||||
True
|
||||
>>> SyncState.from_node(1, '{}').leader is None
|
||||
True
|
||||
>>> SyncState.from_node(1, '{').leader is None
|
||||
True
|
||||
>>> SyncState.from_node(1, '[]').leader is None
|
||||
True
|
||||
>>> SyncState.from_node(1, '{"leader": "leader"}').leader == "leader"
|
||||
True
|
||||
"""
|
||||
if value:
|
||||
try:
|
||||
data = json.loads(value)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except (TypeError, ValueError):
|
||||
data = {}
|
||||
else:
|
||||
data = {}
|
||||
return SyncState(index, data.get('leader'), data.get('sync_standby'))
|
||||
|
||||
def matches(self, name):
|
||||
"""
|
||||
Returns if a node name matches one of the nodes in the sync state
|
||||
|
||||
>>> s = SyncState(1, 'foo', 'bar')
|
||||
>>> s.matches('foo')
|
||||
True
|
||||
>>> s.matches('bar')
|
||||
True
|
||||
>>> s.matches('baz')
|
||||
False
|
||||
>>> s.matches(None)
|
||||
False
|
||||
>>> SyncState(1, None, None).matches('foo')
|
||||
False
|
||||
"""
|
||||
return name is not None and name in (self.leader, self.sync_standby)
|
||||
|
||||
|
||||
class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operation,members,failover,sync')):
|
||||
|
||||
"""Immutable object (namedtuple) which represents PostgreSQL cluster.
|
||||
Consists of the following fields:
|
||||
:param initialize: shows whether this cluster has initialization key stored in DC or not.
|
||||
:param config: global dynamic configuration, reference to `ClusterConfig` object
|
||||
:param leader: `Leader` object which represents current leader of the cluster
|
||||
:param last_leader_operation: int or long object containing position of last known leader operation.
|
||||
This value is stored in `/optime/leader` key
|
||||
:param members: list of Member object, all PostgreSQL cluster members including leader
|
||||
:param failover: reference to `Failover` object
|
||||
:param sync: reference to `SyncState` object, last observed synchronous replication state.
|
||||
"""
|
||||
|
||||
def is_unlocked(self):
|
||||
return not (self.leader and self.leader.name)
|
||||
|
||||
def has_member(self, member_name):
|
||||
return any(m for m in self.members if m.name == member_name)
|
||||
|
||||
def get_member(self, member_name, fallback_to_leader=True):
|
||||
return ([m for m in self.members if m.name == member_name] or [self.leader if fallback_to_leader else None])[0]
|
||||
|
||||
def get_clone_member(self, exclude):
|
||||
exclude = [exclude] + [self.leader.name] if self.leader else []
|
||||
candidates = [m for m in self.members if m.clonefrom and m.is_running and m.name not in exclude]
|
||||
return candidates[randint(0, len(candidates) - 1)] if candidates else self.leader
|
||||
|
||||
def is_paused(self):
|
||||
return self.config and self.config.data.get('pause', False) or False
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class AbstractDCS(object):
|
||||
|
||||
_INITIALIZE = 'initialize'
|
||||
_CONFIG = 'config'
|
||||
_LEADER = 'leader'
|
||||
_FAILOVER = 'failover'
|
||||
_MEMBERS = 'members/'
|
||||
_OPTIME = 'optime'
|
||||
_LEADER_OPTIME = _OPTIME + '/' + _LEADER
|
||||
_SYNC = 'sync'
|
||||
|
||||
def __init__(self, config):
|
||||
"""
|
||||
:param config: dict, reference to config section of selected DCS.
|
||||
i.e.: `zookeeper` for zookeeper, `etcd` for etcd, etc...
|
||||
"""
|
||||
self._name = config['name']
|
||||
self._namespace = '/{0}'.format(config.get('namespace', '/service/').strip('/'))
|
||||
self._base_path = '/'.join([self._namespace, config['scope']])
|
||||
self._set_loop_wait(config.get('loop_wait', 10))
|
||||
|
||||
self._ctl = bool(config.get('patronictl', False))
|
||||
self._cluster = None
|
||||
self._cluster_thread_lock = Lock()
|
||||
self._last_leader_operation = ''
|
||||
self.event = Event()
|
||||
|
||||
def client_path(self, path):
|
||||
return '/'.join([self._base_path, path.lstrip('/')])
|
||||
|
||||
@property
|
||||
def initialize_path(self):
|
||||
return self.client_path(self._INITIALIZE)
|
||||
|
||||
@property
|
||||
def config_path(self):
|
||||
return self.client_path(self._CONFIG)
|
||||
|
||||
@property
|
||||
def members_path(self):
|
||||
return self.client_path(self._MEMBERS)
|
||||
|
||||
@property
|
||||
def member_path(self):
|
||||
return self.client_path(self._MEMBERS + self._name)
|
||||
|
||||
@property
|
||||
def leader_path(self):
|
||||
return self.client_path(self._LEADER)
|
||||
|
||||
@property
|
||||
def failover_path(self):
|
||||
return self.client_path(self._FAILOVER)
|
||||
|
||||
@property
|
||||
def leader_optime_path(self):
|
||||
return self.client_path(self._LEADER_OPTIME)
|
||||
|
||||
@property
|
||||
def sync_path(self):
|
||||
return self.client_path(self._SYNC)
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_ttl(self, ttl):
|
||||
"""Set the new ttl value for leader key"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_retry_timeout(self, retry_timeout):
|
||||
"""Set the new value for retry_timeout"""
|
||||
|
||||
def _set_loop_wait(self, loop_wait):
|
||||
self._loop_wait = loop_wait
|
||||
|
||||
def reload_config(self, config):
|
||||
self._set_loop_wait(config['loop_wait'])
|
||||
self.set_ttl(config['ttl'])
|
||||
self.set_retry_timeout(config['retry_timeout'])
|
||||
|
||||
@property
|
||||
def loop_wait(self):
|
||||
return self._loop_wait
|
||||
|
||||
@abc.abstractmethod
|
||||
def _load_cluster(self):
|
||||
"""Internally this method should build `Cluster` object which
|
||||
represents current state and topology of the cluster in DCS.
|
||||
this method supposed to be called only by `get_cluster` method.
|
||||
|
||||
raise `~DCSError` in case of communication or other problems with DCS.
|
||||
If the current node was running as a master and exception raised,
|
||||
instance would be demoted."""
|
||||
|
||||
def get_cluster(self):
|
||||
with self._cluster_thread_lock:
|
||||
try:
|
||||
self._load_cluster()
|
||||
except:
|
||||
self._cluster = None
|
||||
raise
|
||||
return self._cluster
|
||||
|
||||
@property
|
||||
def cluster(self):
|
||||
with self._cluster_thread_lock:
|
||||
return self._cluster
|
||||
|
||||
def reset_cluster(self):
|
||||
with self._cluster_thread_lock:
|
||||
self._cluster = None
|
||||
|
||||
@abc.abstractmethod
|
||||
def _write_leader_optime(self, last_operation):
|
||||
"""write current xlog location into `/optime/leader` key in DCS
|
||||
:param last_operation: absolute xlog location in bytes
|
||||
:returns: `!True` on success."""
|
||||
|
||||
def write_leader_optime(self, last_operation):
|
||||
if self._last_leader_operation != last_operation and self._write_leader_optime(last_operation):
|
||||
self._last_leader_operation = last_operation
|
||||
|
||||
@abc.abstractmethod
|
||||
def update_leader(self):
|
||||
"""Update leader key (or session) ttl
|
||||
|
||||
:returns: `!True` if leader key (or session) has been updated successfully.
|
||||
If not, `!False` must be returned and current instance would be demoted.
|
||||
|
||||
You have to use CAS (Compare And Swap) operation in order to update leader key,
|
||||
for example for etcd `prevValue` parameter must be used."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def attempt_to_acquire_leader(self, permanent=False):
|
||||
"""Attempt to acquire leader lock
|
||||
This method should create `/leader` key with value=`~self._name`
|
||||
:param permanent: if set to `!True`, the leader key will never expire.
|
||||
Used in patronictl for the external master
|
||||
:returns: `!True` if key has been created successfully.
|
||||
|
||||
Key must be created atomically. In case if key already exists it should not be
|
||||
overwritten and `!False` must be returned"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_failover_value(self, value, index=None):
|
||||
"""Create or update `/failover` key"""
|
||||
|
||||
def manual_failover(self, leader, candidate, scheduled_at=None, index=None):
|
||||
failover_value = {}
|
||||
if leader:
|
||||
failover_value['leader'] = leader
|
||||
|
||||
if candidate:
|
||||
failover_value['member'] = candidate
|
||||
|
||||
if scheduled_at:
|
||||
failover_value['scheduled_at'] = scheduled_at.isoformat()
|
||||
|
||||
return self.set_failover_value(json.dumps(failover_value, separators=(',', ':')), index)
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_config_value(self, value, index=None):
|
||||
"""Create or update `/config` key"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def touch_member(self, data, ttl=None, permanent=False):
|
||||
"""Update member key in DCS.
|
||||
This method should create or update key with the name = '/members/' + `~self._name`
|
||||
and value = data in a given DCS.
|
||||
|
||||
:param data: json serialized information about instance (including connection strings)
|
||||
:param ttl: ttl for member key, optional parameter. If it is None `~self.member_ttl will be used`
|
||||
:param permanent: if set to `!True`, the member key will never expire.
|
||||
Used in patronictl for the external master.
|
||||
:returns: `!True` on success otherwise `!False`
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def take_leader(self):
|
||||
"""This method should create leader key with value = `~self._name` and ttl=`~self.ttl`
|
||||
Since it could be called only on initial cluster bootstrap it could create this key regardless,
|
||||
overwriting the key if necessary."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def initialize(self, create_new=True, sysid=""):
|
||||
"""Race for cluster initialization.
|
||||
|
||||
:param create_new: False if the key should already exist (in the case we are setting the system_id)
|
||||
:param sysid: PostgreSQL cluster system identifier, if specified, is written to the key
|
||||
:returns: `!True` if key has been created successfully.
|
||||
|
||||
this method should create atomically initialize key and return `!True`
|
||||
otherwise it should return `!False`"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def delete_leader(self):
|
||||
"""Voluntarily remove leader key from DCS
|
||||
This method should remove leader key if current instance is the leader"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def cancel_initialization(self):
|
||||
""" Removes the initialize key for a cluster """
|
||||
|
||||
@abc.abstractmethod
|
||||
def delete_cluster(self):
|
||||
"""Delete cluster from DCS"""
|
||||
|
||||
def write_sync_state(self, leader, sync_standby, index=None):
|
||||
return self.set_sync_state_value(json.dumps({'leader': leader, 'sync_standby': sync_standby}), index=index)
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_sync_state_value(self, value, index=None):
|
||||
""""""
|
||||
|
||||
@abc.abstractmethod
|
||||
def delete_sync_state(self, index=None):
|
||||
""""""
|
||||
|
||||
def watch(self, leader_index, timeout):
|
||||
"""If the current node is a master it should just sleep.
|
||||
Any other node should watch for changes of leader key with a given timeout
|
||||
|
||||
:param leader_index: index of a leader key
|
||||
:param timeout: timeout in seconds
|
||||
:returns: `!True` if you would like to reschedule the next run of ha cycle"""
|
||||
|
||||
self.event.wait(timeout)
|
||||
return self.event.isSet()
|
||||
@@ -0,0 +1,326 @@
|
||||
from __future__ import absolute_import
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
import urllib3
|
||||
|
||||
from consul import ConsulException, NotFound, base
|
||||
from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState
|
||||
from patroni.exceptions import DCSError
|
||||
from patroni.utils import Retry, RetryFailedError
|
||||
from urllib3.exceptions import HTTPError
|
||||
from six.moves.urllib.parse import urlencode
|
||||
from six.moves.http_client import HTTPException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConsulError(DCSError):
|
||||
pass
|
||||
|
||||
|
||||
class ConsulInternalError(ConsulException):
|
||||
"""An internal Consul server error occurred"""
|
||||
|
||||
|
||||
class HTTPClient(object):
|
||||
|
||||
def __init__(self, host='127.0.0.1', port=8500, scheme='http', verify=True, timeout=10):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.scheme = scheme
|
||||
self.verify = verify
|
||||
self.set_read_timeout(timeout)
|
||||
self.base_uri = '{0}://{1}:{2}'.format(self.scheme, self.host, self.port)
|
||||
self.http = urllib3.PoolManager(num_pools=10)
|
||||
self._ttl = None
|
||||
|
||||
def set_read_timeout(self, timeout):
|
||||
self._read_timeout = timeout/3.0
|
||||
|
||||
def set_ttl(self, ttl):
|
||||
ret = self._ttl != ttl
|
||||
self._ttl = ttl
|
||||
return ret
|
||||
|
||||
@staticmethod
|
||||
def response(response):
|
||||
data = response.data.decode('utf-8')
|
||||
if response.status == 500:
|
||||
raise ConsulInternalError('{0} {1}'.format(response.status, data))
|
||||
return base.Response(response.status, response.headers, data)
|
||||
|
||||
def uri(self, path, params=None):
|
||||
return '{0}{1}{2}'.format(self.base_uri, path, params and '?' + urlencode(params) or '')
|
||||
|
||||
def __getattr__(self, method):
|
||||
if method not in ('get', 'post', 'put', 'delete'):
|
||||
raise AttributeError("HTTPClient instance has no attribute '{0}'".format(method))
|
||||
|
||||
def wrapper(callback, path, params=None, data=''):
|
||||
# python-consul doesn't allow to specify ttl smaller then 10 seconds
|
||||
# because session_ttl_min defaults to 10s, so we have to do this ugly dirty hack...
|
||||
if method == 'put' and path == '/v1/session/create':
|
||||
ttl = '"ttl": "{0}s"'.format(self._ttl)
|
||||
if not data or data == '{}':
|
||||
data = '{' + ttl + '}'
|
||||
else:
|
||||
data = data[:-1] + ', ' + ttl + '}'
|
||||
kwargs = {'retries': 0, 'preload_content': False, 'body': data}
|
||||
if method == 'get' and isinstance(params, dict) and 'index' in params:
|
||||
kwargs['timeout'] = (float(params['wait'][:-1]) if 'wait' in params else 300) + 1
|
||||
else:
|
||||
kwargs['timeout'] = self._read_timeout
|
||||
return callback(self.response(self.http.request(method.upper(), self.uri(path, params), **kwargs)))
|
||||
return wrapper
|
||||
|
||||
|
||||
class ConsulClient(base.Consul):
|
||||
|
||||
@staticmethod
|
||||
def connect(host, port, scheme, verify=True):
|
||||
return HTTPClient(host, port, scheme, verify)
|
||||
|
||||
|
||||
def catch_consul_errors(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except (RetryFailedError, ConsulException, HTTPException, HTTPError, socket.error, socket.timeout):
|
||||
return False
|
||||
return wrapper
|
||||
|
||||
|
||||
class Consul(AbstractDCS):
|
||||
|
||||
def __init__(self, config):
|
||||
super(Consul, self).__init__(config)
|
||||
self._scope = config['scope']
|
||||
self._session = None
|
||||
self.__do_not_watch = False
|
||||
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
|
||||
retry_exceptions=(ConsulInternalError, HTTPException,
|
||||
HTTPError, socket.error, socket.timeout))
|
||||
|
||||
self._my_member_data = None
|
||||
host, port = config.get('host', '127.0.0.1:8500').split(':')
|
||||
self._client = ConsulClient(host=host, port=port)
|
||||
self.set_retry_timeout(config['retry_timeout'])
|
||||
self.set_ttl(config.get('ttl') or 30)
|
||||
self._last_session_refresh = 0
|
||||
if not self._ctl:
|
||||
self.create_session()
|
||||
|
||||
def retry(self, *args, **kwargs):
|
||||
return self._retry.copy()(*args, **kwargs)
|
||||
|
||||
def create_session(self):
|
||||
while not self._session:
|
||||
try:
|
||||
self.refresh_session()
|
||||
except ConsulError:
|
||||
logger.info('waiting on consul')
|
||||
time.sleep(5)
|
||||
|
||||
def set_ttl(self, ttl):
|
||||
if self._client.http.set_ttl(ttl/2.0): # Consul multiplies the TTL by 2x
|
||||
self._session = None
|
||||
self.__do_not_watch = True
|
||||
|
||||
def set_retry_timeout(self, retry_timeout):
|
||||
self._retry.deadline = retry_timeout
|
||||
self._client.http.set_read_timeout(retry_timeout)
|
||||
|
||||
def _do_refresh_session(self):
|
||||
""":returns: `!True` if it had to create new session"""
|
||||
if self._session and self._last_session_refresh + self._loop_wait > time.time():
|
||||
return False
|
||||
|
||||
if self._session:
|
||||
try:
|
||||
self._client.session.renew(self._session)
|
||||
except NotFound:
|
||||
self._session = None
|
||||
ret = not self._session
|
||||
if ret:
|
||||
self._session = self._client.session.create(name=self._scope + '-' + self._name,
|
||||
lock_delay=0.001, behavior='delete')
|
||||
self._last_session_refresh = time.time()
|
||||
return ret
|
||||
|
||||
def refresh_session(self):
|
||||
try:
|
||||
return self.retry(self._do_refresh_session)
|
||||
except (ConsulException, RetryFailedError):
|
||||
logger.exception('refresh_session')
|
||||
raise ConsulError('Failed to renew/create session')
|
||||
|
||||
def client_path(self, path):
|
||||
return super(Consul, self).client_path(path)[1:]
|
||||
|
||||
@staticmethod
|
||||
def member(node):
|
||||
return Member.from_node(node['ModifyIndex'], os.path.basename(node['Key']), node.get('Session'), node['Value'])
|
||||
|
||||
def _load_cluster(self):
|
||||
try:
|
||||
path = self.client_path('/')
|
||||
_, results = self.retry(self._client.kv.get, path, recurse=True)
|
||||
|
||||
if results is None:
|
||||
raise NotFound
|
||||
|
||||
nodes = {}
|
||||
for node in results:
|
||||
node['Value'] = (node['Value'] or b'').decode('utf-8')
|
||||
nodes[os.path.relpath(node['Key'], path)] = node
|
||||
|
||||
# get initialize flag
|
||||
initialize = nodes.get(self._INITIALIZE)
|
||||
initialize = initialize and initialize['Value']
|
||||
|
||||
# get global dynamic configuration
|
||||
config = nodes.get(self._CONFIG)
|
||||
config = config and ClusterConfig.from_node(config['ModifyIndex'], config['Value'])
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = nodes.get(self._LEADER_OPTIME)
|
||||
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['Value'])
|
||||
|
||||
# get list of members
|
||||
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
|
||||
# get leader
|
||||
leader = nodes.get(self._LEADER)
|
||||
if not self._ctl and leader and leader['Value'] == self._name \
|
||||
and self._session != leader.get('Session', 'x'):
|
||||
logger.info('I am leader but not owner of the session. Removing leader node')
|
||||
self._client.kv.delete(self.leader_path, cas=leader['ModifyIndex'])
|
||||
leader = None
|
||||
|
||||
if leader:
|
||||
member = Member(-1, leader['Value'], None, {})
|
||||
member = ([m for m in members if m.name == leader['Value']] or [member])[0]
|
||||
leader = Leader(leader['ModifyIndex'], leader.get('Session'), member)
|
||||
|
||||
# failover key
|
||||
failover = nodes.get(self._FAILOVER)
|
||||
if failover:
|
||||
failover = Failover.from_node(failover['ModifyIndex'], failover['Value'])
|
||||
|
||||
# get synchronization state
|
||||
sync = nodes.get(self._SYNC)
|
||||
sync = SyncState.from_node(sync and sync['ModifyIndex'], sync and sync['Value'])
|
||||
|
||||
self._cluster = Cluster(initialize, config, leader, last_leader_operation, members, failover, sync)
|
||||
except NotFound:
|
||||
self._cluster = Cluster(None, None, None, None, [], None, None)
|
||||
except:
|
||||
logger.exception('get_cluster')
|
||||
raise ConsulError('Consul is not responding properly')
|
||||
|
||||
def touch_member(self, data, **kwargs):
|
||||
cluster = self.cluster
|
||||
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
|
||||
create_member = self.refresh_session()
|
||||
|
||||
if member and (create_member or member.session != self._session):
|
||||
try:
|
||||
self._client.kv.delete(self.member_path)
|
||||
create_member = True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if not create_member and member and data == self._my_member_data:
|
||||
return True
|
||||
|
||||
try:
|
||||
args = {} if kwargs.get('permanent', False) else {'acquire': self._session}
|
||||
self._client.kv.put(self.member_path, data, **args)
|
||||
self._my_member_data = data
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception('touch_member')
|
||||
return False
|
||||
|
||||
@catch_consul_errors
|
||||
def attempt_to_acquire_leader(self, permanent=False):
|
||||
if not self._session and not permanent:
|
||||
self.refresh_session()
|
||||
|
||||
args = {} if permanent else {'acquire': self._session}
|
||||
ret = self.retry(self._client.kv.put, self.leader_path, self._name, **args)
|
||||
if not ret:
|
||||
logger.info('Could not take out TTL lock')
|
||||
return ret
|
||||
|
||||
def take_leader(self):
|
||||
return self.attempt_to_acquire_leader()
|
||||
|
||||
@catch_consul_errors
|
||||
def set_failover_value(self, value, index=None):
|
||||
return self._client.kv.put(self.failover_path, value, cas=index)
|
||||
|
||||
@catch_consul_errors
|
||||
def set_config_value(self, value, index=None):
|
||||
return self._client.kv.put(self.config_path, value, cas=index)
|
||||
|
||||
@catch_consul_errors
|
||||
def _write_leader_optime(self, last_operation):
|
||||
return self._client.kv.put(self.leader_optime_path, last_operation)
|
||||
|
||||
@catch_consul_errors
|
||||
def update_leader(self):
|
||||
if self._session:
|
||||
self.retry(self._client.session.renew, self._session)
|
||||
self._last_session_refresh = time.time()
|
||||
return bool(self._session)
|
||||
|
||||
@catch_consul_errors
|
||||
def initialize(self, create_new=True, sysid=''):
|
||||
kwargs = {'cas': 0} if create_new else {}
|
||||
return self.retry(self._client.kv.put, self.initialize_path, sysid, **kwargs)
|
||||
|
||||
@catch_consul_errors
|
||||
def cancel_initialization(self):
|
||||
return self.retry(self._client.kv.delete, self.initialize_path)
|
||||
|
||||
@catch_consul_errors
|
||||
def delete_cluster(self):
|
||||
return self.retry(self._client.kv.delete, self.client_path(''), recurse=True)
|
||||
|
||||
@catch_consul_errors
|
||||
def delete_leader(self):
|
||||
cluster = self.cluster
|
||||
if cluster and isinstance(cluster.leader, Leader) and cluster.leader.name == self._name:
|
||||
return self._client.kv.delete(self.leader_path, cas=cluster.leader.index)
|
||||
|
||||
@catch_consul_errors
|
||||
def set_sync_state_value(self, value, index=None):
|
||||
return self._client.kv.put(self.sync_path, value, cas=index)
|
||||
|
||||
@catch_consul_errors
|
||||
def delete_sync_state(self, index=None):
|
||||
return self._client.kv.delete(self.sync_path, cas=index)
|
||||
|
||||
def watch(self, leader_index, timeout):
|
||||
if self.__do_not_watch:
|
||||
self.__do_not_watch = False
|
||||
return True
|
||||
|
||||
if leader_index:
|
||||
end_time = time.time() + timeout
|
||||
while timeout >= 1:
|
||||
try:
|
||||
idx, _ = self._client.kv.get(self.leader_path, index=leader_index, wait=str(timeout) + 's')
|
||||
return str(idx) != str(leader_index)
|
||||
except (ConsulException, HTTPException, HTTPError, socket.error, socket.timeout):
|
||||
logging.exception('watch')
|
||||
|
||||
timeout = end_time - time.time()
|
||||
|
||||
try:
|
||||
return super(Consul, self).watch(None, timeout)
|
||||
finally:
|
||||
self.event.clear()
|
||||
@@ -0,0 +1,541 @@
|
||||
from __future__ import absolute_import
|
||||
import etcd
|
||||
import logging
|
||||
import os
|
||||
import urllib3.util.connection
|
||||
import random
|
||||
import requests
|
||||
import socket
|
||||
import time
|
||||
|
||||
from dns.exception import DNSException
|
||||
from dns import resolver
|
||||
from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState
|
||||
from patroni.exceptions import DCSError
|
||||
from patroni.utils import Retry, RetryFailedError
|
||||
from urllib3.exceptions import HTTPError, ReadTimeoutError
|
||||
from requests.exceptions import RequestException
|
||||
from six.moves.queue import Queue
|
||||
from six.moves.http_client import HTTPException
|
||||
from six.moves.urllib_parse import urlparse
|
||||
from threading import Thread
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EtcdError(DCSError):
|
||||
pass
|
||||
|
||||
|
||||
class DnsCachingResolver(Thread):
|
||||
|
||||
def __init__(self, cache_time=600.0, cache_fail_time=30.0):
|
||||
super(DnsCachingResolver, self).__init__()
|
||||
self._cache = {}
|
||||
self._cache_time = cache_time
|
||||
self._cache_fail_time = cache_fail_time
|
||||
self._resolve_queue = Queue()
|
||||
self.daemon = True
|
||||
self.start()
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
(host, port), attempt = self._resolve_queue.get()
|
||||
response = self._do_resolve(host, port)
|
||||
if response:
|
||||
self._cache[(host, port)] = (time.time(), response)
|
||||
else:
|
||||
if attempt < 10:
|
||||
self.resolve_async(host, port, attempt + 1)
|
||||
time.sleep(1)
|
||||
|
||||
def resolve(self, host, port):
|
||||
current_time = time.time()
|
||||
cached_time, response = self._cache.get((host, port), (0, []))
|
||||
time_passed = current_time - cached_time
|
||||
if time_passed > self._cache_time or (not response and time_passed > self._cache_fail_time):
|
||||
new_response = self._do_resolve(host, port)
|
||||
if new_response:
|
||||
self._cache[(host, port)] = (current_time, new_response)
|
||||
response = new_response
|
||||
return response
|
||||
|
||||
def resolve_async(self, host, port, attempt=0):
|
||||
self._resolve_queue.put(((host, port), attempt))
|
||||
|
||||
@staticmethod
|
||||
def _do_resolve(host, port):
|
||||
try:
|
||||
return socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP)
|
||||
except socket.gaierror:
|
||||
logger.warning('failed to resolve host %s', host)
|
||||
return []
|
||||
|
||||
|
||||
class Client(etcd.Client):
|
||||
|
||||
def __init__(self, config, dns_resolver, cache_ttl=300):
|
||||
self._dns_resolver = dns_resolver
|
||||
self.set_machines_cache_ttl(cache_ttl)
|
||||
self._machines_cache_updated = 0
|
||||
args = {p: config.get(p) for p in ('host', 'port', 'protocol', 'use_proxies', 'username', 'password',
|
||||
'cert', 'ca_cert') if config.get(p)}
|
||||
super(Client, self).__init__(read_timeout=config['retry_timeout'], **args)
|
||||
self._config = config
|
||||
self._load_machines_cache()
|
||||
self._allow_reconnect = not self._use_proxies
|
||||
|
||||
def _build_request_parameters(self):
|
||||
kwargs = {'headers': self._get_headers(), 'redirect': self.allow_redirect}
|
||||
|
||||
# calculate the number of retries and timeout *per node*
|
||||
# actual number of retries depends on the number of nodes
|
||||
etcd_nodes = len(self._machines_cache) + 1
|
||||
kwargs['retries'] = 0 if etcd_nodes > 3 else (1 if etcd_nodes > 1 else 2)
|
||||
|
||||
# if etcd_nodes > 3:
|
||||
# kwargs.update({'retries': 0, 'timeout': float(self.read_timeout)/etcd_nodes})
|
||||
# elif etcd_nodes > 1:
|
||||
# kwargs.update({'retries': 1, 'timeout': self.read_timeout/2.0/etcd_nodes})
|
||||
# else:
|
||||
# kwargs.update({'retries': 2, 'timeout': self.read_timeout/3.0})
|
||||
kwargs['timeout'] = self.read_timeout/float(kwargs['retries'] + 1)/etcd_nodes
|
||||
return kwargs
|
||||
|
||||
def set_machines_cache_ttl(self, cache_ttl):
|
||||
self._machines_cache_ttl = cache_ttl
|
||||
|
||||
@property
|
||||
def machines(self):
|
||||
"""Original `machines` method(property) of `etcd.Client` class raise exception
|
||||
when it failed to get list of etcd cluster members. This method is being called
|
||||
only when request failed on one of the etcd members during `api_execute` call.
|
||||
For us it's more important to execute original request rather then get new topology
|
||||
of etcd cluster. So we will catch this exception and return empty list of machines.
|
||||
Later, during next `api_execute` call we will forcefully update machines_cache.
|
||||
|
||||
Also this method implements the same timeout-retry logic as `api_execute`, because
|
||||
the original method was retrying 2 times with the `read_timeout` on each node."""
|
||||
|
||||
kwargs = self._build_request_parameters()
|
||||
|
||||
while True:
|
||||
try:
|
||||
response = self.http.request(self._MGET, self._base_uri + self.version_prefix + '/machines', **kwargs)
|
||||
machines = [n.strip() for n in self._handle_server_response(response).data.decode('utf-8').split(',')]
|
||||
logger.debug("Retrieved list of machines: %s", machines)
|
||||
random.shuffle(machines)
|
||||
for url in machines:
|
||||
r = urlparse(url)
|
||||
port = r.port or (443 if r.scheme == 'https' else 80)
|
||||
self._dns_resolver.resolve_async(r.hostname, port)
|
||||
return machines
|
||||
except Exception as e:
|
||||
# We can't get the list of machines, if one server is in the
|
||||
# machines cache, try on it
|
||||
logger.error("Failed to get list of machines from %s%s: %r", self._base_uri, self.version_prefix, e)
|
||||
if self._machines_cache:
|
||||
self._base_uri = self._machines_cache.pop(0)
|
||||
logger.info("Retrying on %s", self._base_uri)
|
||||
elif self._update_machines_cache:
|
||||
raise etcd.EtcdException("Could not get the list of servers, "
|
||||
"maybe you provided the wrong "
|
||||
"host(s) to connect to?")
|
||||
else:
|
||||
return []
|
||||
|
||||
def set_read_timeout(self, timeout):
|
||||
self._read_timeout = timeout
|
||||
|
||||
def _do_http_request(self, request_executor, method, url, fields=None, **kwargs):
|
||||
try:
|
||||
response = request_executor(method, url, fields=fields, **kwargs)
|
||||
response.data.decode('utf-8')
|
||||
self._check_cluster_id(response)
|
||||
except (HTTPError, HTTPException, socket.error, socket.timeout) as e:
|
||||
if (isinstance(fields, dict) and fields.get("wait") == "true" and
|
||||
isinstance(e, ReadTimeoutError)):
|
||||
logger.debug("Watch timed out.")
|
||||
raise etcd.EtcdWatchTimedOut("Watch timed out: {0}".format(e), cause=e)
|
||||
logger.error("Request to server %s failed: %r", self._base_uri, e)
|
||||
logger.info("Reconnection allowed, looking for another server.")
|
||||
self._base_uri = self._next_server(cause=e)
|
||||
response = False
|
||||
return response
|
||||
|
||||
def api_execute(self, path, method, params=None, timeout=None):
|
||||
if not path.startswith('/'):
|
||||
raise ValueError('Path does not start with /')
|
||||
|
||||
kwargs = {'fields': params, 'preload_content': False}
|
||||
|
||||
if method in [self._MGET, self._MDELETE]:
|
||||
request_executor = self.http.request
|
||||
elif method in [self._MPUT, self._MPOST]:
|
||||
request_executor = self.http.request_encode_body
|
||||
kwargs['encode_multipart'] = False
|
||||
else:
|
||||
raise etcd.EtcdException('HTTP method {0} not supported'.format(method))
|
||||
|
||||
# Update machines_cache if previous attempt of update has failed
|
||||
if self._update_machines_cache:
|
||||
self._load_machines_cache()
|
||||
elif time.time() - self._machines_cache_updated > self._machines_cache_ttl:
|
||||
self._machines_cache = self.machines
|
||||
if self._base_uri in self._machines_cache:
|
||||
self._machines_cache.remove(self._base_uri)
|
||||
self._machines_cache_updated = time.time()
|
||||
|
||||
kwargs.update(self._build_request_parameters())
|
||||
|
||||
if timeout is not None:
|
||||
kwargs.update({'retries': 0, 'timeout': timeout})
|
||||
|
||||
response = False
|
||||
|
||||
try:
|
||||
some_request_failed = False
|
||||
while not response:
|
||||
response = self._do_http_request(request_executor, method, self._base_uri + path, **kwargs)
|
||||
|
||||
if response is False:
|
||||
some_request_failed = True
|
||||
if some_request_failed and not self._use_proxies:
|
||||
self._machines_cache = self.machines
|
||||
if self._base_uri in self._machines_cache:
|
||||
self._machines_cache.remove(self._base_uri)
|
||||
except etcd.EtcdConnectionFailed:
|
||||
self._update_machines_cache = True
|
||||
if not response:
|
||||
raise
|
||||
return self._handle_server_response(response)
|
||||
|
||||
@staticmethod
|
||||
def get_srv_record(host):
|
||||
try:
|
||||
return [(r.target.to_text(True), r.port) for r in resolver.query(host, 'SRV')]
|
||||
except DNSException:
|
||||
return []
|
||||
|
||||
def _get_machines_cache_from_srv(self, srv):
|
||||
"""Fetch list of etcd-cluster member by resolving _etcd-server._tcp. SRV record.
|
||||
This record should contain list of host and peer ports which could be used to run
|
||||
'GET http://{host}:{port}/members' request (peer protocol)"""
|
||||
|
||||
ret = []
|
||||
for r in ['-client-ssl', '-client', '-ssl', '', '-server-ssl', '-server']:
|
||||
protocol = 'https' if '-ssl' in r else 'http'
|
||||
endpoint = '/members' if '-server' in r else ''
|
||||
for host, port in self.get_srv_record('_etcd{0}._tcp.{1}'.format(r, srv)):
|
||||
url = '{0}://{1}:{2}{3}'.format(protocol, host, port, endpoint)
|
||||
if endpoint:
|
||||
try:
|
||||
response = requests.get(url, timeout=self.read_timeout, verify=False)
|
||||
if response.ok:
|
||||
for member in response.json():
|
||||
ret.extend(member['clientURLs'])
|
||||
break
|
||||
except RequestException:
|
||||
logger.exception('GET %s', url)
|
||||
else:
|
||||
ret.append(url)
|
||||
if ret:
|
||||
self._protocol = protocol
|
||||
break
|
||||
else:
|
||||
logger.warning('Can not resolve SRV for %s', srv)
|
||||
return list(set(ret))
|
||||
|
||||
def _get_machines_cache_from_dns(self, host, port):
|
||||
"""One host might be resolved into multiple ip addresses. We will make list out of it"""
|
||||
if self.protocol == 'http':
|
||||
ret = []
|
||||
for af, _, _, _, sa in self._dns_resolver.resolve(host, port):
|
||||
host, port = sa[:2]
|
||||
if af == socket.AF_INET6:
|
||||
host = '[{0}]'.format(host)
|
||||
ret.append('{0}://{1}:{2}'.format(self.protocol, host, port))
|
||||
if ret:
|
||||
return list(set(ret))
|
||||
return ['{0}://{1}:{2}'.format(self.protocol, host, port)]
|
||||
|
||||
def _load_machines_cache(self):
|
||||
"""This method should fill up `_machines_cache` from scratch.
|
||||
It could happen only in two cases:
|
||||
1. During class initialization
|
||||
2. When all etcd members failed"""
|
||||
|
||||
self._update_machines_cache = True
|
||||
|
||||
if 'srv' not in self._config and 'host' not in self._config:
|
||||
raise Exception('Neither srv nor host url are defined in etcd section of config')
|
||||
|
||||
if self._use_proxies:
|
||||
self._machines_cache = ['{0}://{1}:{2}'.format(self.protocol, self._config['host'], self._config['port'])]
|
||||
else:
|
||||
self._machines_cache = []
|
||||
|
||||
if 'srv' in self._config:
|
||||
self._machines_cache = self._get_machines_cache_from_srv(self._config['srv'])
|
||||
|
||||
if not self._machines_cache and 'host' in self._config:
|
||||
self._machines_cache = self._get_machines_cache_from_dns(self._config['host'], self._config['port'])
|
||||
|
||||
# Can not bootstrap list of etcd-cluster members, giving up
|
||||
if not self._machines_cache:
|
||||
raise etcd.EtcdException
|
||||
|
||||
# After filling up initial list of machines_cache we should ask etcd-cluster about actual list
|
||||
self._base_uri = self._next_server()
|
||||
self._machines_cache = self.machines
|
||||
|
||||
if self._base_uri in self._machines_cache:
|
||||
self._machines_cache.remove(self._base_uri)
|
||||
|
||||
self._update_machines_cache = False
|
||||
self._machines_cache_updated = time.time()
|
||||
|
||||
|
||||
def catch_etcd_errors(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs) is not None
|
||||
except (RetryFailedError, etcd.EtcdException):
|
||||
return False
|
||||
except:
|
||||
logger.exception("")
|
||||
raise EtcdError("unexpected error")
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class Etcd(AbstractDCS):
|
||||
|
||||
def __init__(self, config):
|
||||
super(Etcd, self).__init__(config)
|
||||
self._ttl = int(config.get('ttl') or 30)
|
||||
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
|
||||
retry_exceptions=(etcd.EtcdLeaderElectionInProgress,
|
||||
etcd.EtcdWatcherCleared,
|
||||
etcd.EtcdEventIndexCleared))
|
||||
self._client = self.get_etcd_client(config)
|
||||
self.__do_not_watch = False
|
||||
|
||||
def retry(self, *args, **kwargs):
|
||||
return self._retry.copy()(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def get_etcd_client(config):
|
||||
if 'proxy' in config:
|
||||
config['use_proxies'] = True
|
||||
config['url'] = config['proxy']
|
||||
|
||||
if 'url' in config:
|
||||
r = urlparse(config['url'])
|
||||
config.update({'protocol': r.scheme, 'host': r.hostname, 'port': r.port or 2379,
|
||||
'username': r.username, 'password': r.password})
|
||||
elif 'host' in config:
|
||||
host, port = (config['host'] + ':2379').split(':')[:2]
|
||||
config['host'] = host
|
||||
if 'port' not in config:
|
||||
config['port'] = int(port)
|
||||
|
||||
if config.get('cacert'):
|
||||
config['ca_cert'] = config.pop('cacert')
|
||||
|
||||
if config.get('key') and config.get('cert'):
|
||||
config['cert'] = (config['cert'], config['key'])
|
||||
|
||||
for p in ('discovery_srv', 'srv_domain'):
|
||||
if p in config:
|
||||
config['srv'] = config.pop(p)
|
||||
|
||||
dns_resolver = DnsCachingResolver()
|
||||
|
||||
def create_connection_patched(address, timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
|
||||
source_address=None, socket_options=None):
|
||||
host, port = address
|
||||
if host.startswith('['):
|
||||
host = host.strip('[]')
|
||||
err = None
|
||||
for af, socktype, proto, _, sa in dns_resolver.resolve(host, port):
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(af, socktype, proto)
|
||||
if socket_options:
|
||||
for opt in socket_options:
|
||||
sock.setsockopt(*opt)
|
||||
if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT:
|
||||
sock.settimeout(timeout)
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
sock.connect(sa)
|
||||
return sock
|
||||
|
||||
except socket.error as e:
|
||||
err = e
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
sock = None
|
||||
|
||||
if err is not None:
|
||||
raise err
|
||||
|
||||
raise socket.error("getaddrinfo returns an empty list")
|
||||
|
||||
urllib3.util.connection.create_connection = create_connection_patched
|
||||
|
||||
client = None
|
||||
while not client:
|
||||
try:
|
||||
client = Client(config, dns_resolver)
|
||||
except etcd.EtcdException:
|
||||
logger.info('waiting on etcd')
|
||||
time.sleep(5)
|
||||
return client
|
||||
|
||||
def set_ttl(self, ttl):
|
||||
ttl = int(ttl)
|
||||
self.__do_not_watch = self._ttl != ttl
|
||||
self._ttl = ttl
|
||||
self._client.set_machines_cache_ttl(ttl*10)
|
||||
|
||||
def set_retry_timeout(self, retry_timeout):
|
||||
self._retry.deadline = retry_timeout
|
||||
self._client.set_read_timeout(retry_timeout)
|
||||
|
||||
@staticmethod
|
||||
def member(node):
|
||||
return Member.from_node(node.modifiedIndex, os.path.basename(node.key), node.ttl, node.value)
|
||||
|
||||
def _load_cluster(self):
|
||||
try:
|
||||
result = self.retry(self._client.read, self.client_path(''), recursive=True)
|
||||
nodes = {os.path.relpath(node.key, result.key): node for node in result.leaves}
|
||||
|
||||
# get initialize flag
|
||||
initialize = nodes.get(self._INITIALIZE)
|
||||
initialize = initialize and initialize.value
|
||||
|
||||
# get global dynamic configuration
|
||||
config = nodes.get(self._CONFIG)
|
||||
config = config and ClusterConfig.from_node(config.modifiedIndex, config.value)
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = nodes.get(self._LEADER_OPTIME)
|
||||
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation.value)
|
||||
|
||||
# get list of members
|
||||
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
|
||||
# get leader
|
||||
leader = nodes.get(self._LEADER)
|
||||
if leader:
|
||||
member = Member(-1, leader.value, None, {})
|
||||
member = ([m for m in members if m.name == leader.value] or [member])[0]
|
||||
index = result.etcd_index if result.etcd_index > leader.modifiedIndex else leader.modifiedIndex + 1
|
||||
leader = Leader(index, leader.ttl, member)
|
||||
|
||||
# failover key
|
||||
failover = nodes.get(self._FAILOVER)
|
||||
if failover:
|
||||
failover = Failover.from_node(failover.modifiedIndex, failover.value)
|
||||
|
||||
# get synchronization state
|
||||
sync = nodes.get(self._SYNC)
|
||||
sync = SyncState.from_node(sync and sync.modifiedIndex, sync and sync.value)
|
||||
|
||||
self._cluster = Cluster(initialize, config, leader, last_leader_operation, members, failover, sync)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
self._cluster = Cluster(None, None, None, None, [], None, None)
|
||||
except:
|
||||
logger.exception('get_cluster')
|
||||
raise EtcdError('Etcd is not responding properly')
|
||||
|
||||
@catch_etcd_errors
|
||||
def touch_member(self, data, ttl=None, permanent=False):
|
||||
return self.retry(self._client.set, self.member_path, data, None if permanent else ttl or self._ttl)
|
||||
|
||||
@catch_etcd_errors
|
||||
def take_leader(self):
|
||||
return self.retry(self._client.set, self.leader_path, self._name, self._ttl)
|
||||
|
||||
def attempt_to_acquire_leader(self, permanent=False):
|
||||
try:
|
||||
return bool(self.retry(self._client.write,
|
||||
self.leader_path,
|
||||
self._name,
|
||||
ttl=None if permanent else self._ttl,
|
||||
prevExist=False))
|
||||
except etcd.EtcdAlreadyExist:
|
||||
logger.info('Could not take out TTL lock')
|
||||
except (RetryFailedError, etcd.EtcdException):
|
||||
pass
|
||||
return False
|
||||
|
||||
@catch_etcd_errors
|
||||
def set_failover_value(self, value, index=None):
|
||||
return self._client.write(self.failover_path, value, prevIndex=index or 0)
|
||||
|
||||
@catch_etcd_errors
|
||||
def set_config_value(self, value, index=None):
|
||||
return self._client.write(self.config_path, value, prevIndex=index or 0)
|
||||
|
||||
@catch_etcd_errors
|
||||
def _write_leader_optime(self, last_operation):
|
||||
return self._client.set(self.leader_optime_path, last_operation)
|
||||
|
||||
@catch_etcd_errors
|
||||
def update_leader(self):
|
||||
return self.retry(self._client.test_and_set, self.leader_path, self._name, self._name, self._ttl)
|
||||
|
||||
@catch_etcd_errors
|
||||
def initialize(self, create_new=True, sysid=""):
|
||||
return self.retry(self._client.write, self.initialize_path, sysid, prevExist=(not create_new))
|
||||
|
||||
@catch_etcd_errors
|
||||
def delete_leader(self):
|
||||
return self._client.delete(self.leader_path, prevValue=self._name)
|
||||
|
||||
@catch_etcd_errors
|
||||
def cancel_initialization(self):
|
||||
return self.retry(self._client.delete, self.initialize_path)
|
||||
|
||||
@catch_etcd_errors
|
||||
def delete_cluster(self):
|
||||
return self.retry(self._client.delete, self.client_path(''), recursive=True)
|
||||
|
||||
@catch_etcd_errors
|
||||
def set_sync_state_value(self, value, index=None):
|
||||
return self._client.write(self.sync_path, value, prevIndex=index or 0)
|
||||
|
||||
@catch_etcd_errors
|
||||
def delete_sync_state(self, index=None):
|
||||
return self.retry(self._client.delete, self.sync_path, prevIndex=index or 0)
|
||||
|
||||
def watch(self, leader_index, timeout):
|
||||
if self.__do_not_watch:
|
||||
self.__do_not_watch = False
|
||||
return True
|
||||
|
||||
if leader_index:
|
||||
end_time = time.time() + timeout
|
||||
|
||||
while timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect
|
||||
try:
|
||||
self._client.watch(self.leader_path, index=leader_index, timeout=timeout + 0.5)
|
||||
# Synchronous work of all cluster members with etcd is less expensive
|
||||
# than reestablishing http connection every time from every replica.
|
||||
return True
|
||||
except etcd.EtcdWatchTimedOut:
|
||||
self._client.http.clear()
|
||||
return False
|
||||
except etcd.EtcdException:
|
||||
logger.exception('watch')
|
||||
|
||||
timeout = end_time - time.time()
|
||||
|
||||
try:
|
||||
return super(Etcd, self).watch(None, timeout)
|
||||
finally:
|
||||
self.event.clear()
|
||||
@@ -0,0 +1,74 @@
|
||||
import logging
|
||||
import random
|
||||
import requests
|
||||
import time
|
||||
|
||||
from patroni.dcs.zookeeper import ZooKeeper
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExhibitorEnsembleProvider(object):
|
||||
|
||||
TIMEOUT = 3.1
|
||||
|
||||
def __init__(self, hosts, port, uri_path='/exhibitor/v1/cluster/list', poll_interval=300):
|
||||
self._exhibitor_port = port
|
||||
self._uri_path = uri_path
|
||||
self._poll_interval = poll_interval
|
||||
self._exhibitors = hosts
|
||||
self._master_exhibitors = hosts
|
||||
self._zookeeper_hosts = ''
|
||||
self._next_poll = None
|
||||
while not self.poll():
|
||||
logger.info('waiting on exhibitor')
|
||||
time.sleep(5)
|
||||
|
||||
def poll(self):
|
||||
if self._next_poll and self._next_poll > time.time():
|
||||
return False
|
||||
|
||||
json = self._query_exhibitors(self._exhibitors)
|
||||
if not json:
|
||||
json = self._query_exhibitors(self._master_exhibitors)
|
||||
|
||||
if isinstance(json, dict) and 'servers' in json and 'port' in json:
|
||||
self._next_poll = time.time() + self._poll_interval
|
||||
zookeeper_hosts = ','.join([h + ':' + str(json['port']) for h in sorted(json['servers'])])
|
||||
if self._zookeeper_hosts != zookeeper_hosts:
|
||||
logger.info('ZooKeeper connection string has changed: %s => %s', self._zookeeper_hosts, zookeeper_hosts)
|
||||
self._zookeeper_hosts = zookeeper_hosts
|
||||
self._exhibitors = json['servers']
|
||||
return True
|
||||
return False
|
||||
|
||||
def _query_exhibitors(self, exhibitors):
|
||||
random.shuffle(exhibitors)
|
||||
for host in exhibitors:
|
||||
uri = 'http://{0}:{1}{2}'.format(host, self._exhibitor_port, self._uri_path)
|
||||
try:
|
||||
response = requests.get(uri, timeout=self.TIMEOUT)
|
||||
return response.json()
|
||||
except RequestException:
|
||||
pass
|
||||
return None
|
||||
|
||||
@property
|
||||
def zookeeper_hosts(self):
|
||||
return self._zookeeper_hosts
|
||||
|
||||
|
||||
class Exhibitor(ZooKeeper):
|
||||
|
||||
def __init__(self, config):
|
||||
interval = config.get('poll_interval', 300)
|
||||
self._ensemble_provider = ExhibitorEnsembleProvider(config['hosts'], config['port'], poll_interval=interval)
|
||||
config = config.copy()
|
||||
config['hosts'] = self._ensemble_provider.zookeeper_hosts
|
||||
super(Exhibitor, self).__init__(config)
|
||||
|
||||
def _load_cluster(self):
|
||||
if self._ensemble_provider.poll():
|
||||
self._client.set_hosts(self._ensemble_provider.zookeeper_hosts)
|
||||
return super(Exhibitor, self)._load_cluster()
|
||||
@@ -0,0 +1,332 @@
|
||||
import logging
|
||||
|
||||
from kazoo.client import KazooClient, KazooState
|
||||
from kazoo.exceptions import NoNodeError, NodeExistsError
|
||||
from kazoo.handlers.threading import SequentialThreadingHandler
|
||||
from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState
|
||||
from patroni.exceptions import DCSError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZooKeeperError(DCSError):
|
||||
pass
|
||||
|
||||
|
||||
class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
|
||||
|
||||
def __init__(self, connect_timeout):
|
||||
super(PatroniSequentialThreadingHandler, self).__init__()
|
||||
self.set_connect_timeout(connect_timeout)
|
||||
|
||||
def set_connect_timeout(self, connect_timeout):
|
||||
self._connect_timeout = max(1.0, connect_timeout/2.0) # try to connect to zookeeper node during loop_wait/2
|
||||
|
||||
def create_connection(self, *args, **kwargs):
|
||||
"""This method is trying to establish connection with one of the zookeeper nodes.
|
||||
Somehow strategy "fail earlier and retry more often" works way better comparing to
|
||||
the original strategy "try to connect with specified timeout".
|
||||
Since we want to try connect to zookeeper more often (with the smaller connect_timeout),
|
||||
he have to override `create_connection` method in the `SequentialThreadingHandler`
|
||||
class (which is used by `kazoo.Client`).
|
||||
|
||||
:param args: always contains `tuple(host, port)` as the first element and could contain
|
||||
`connect_timeout` (negotiated session timeout) as the second element."""
|
||||
|
||||
args = list(args)
|
||||
if len(args) == 1:
|
||||
args.append(self._connect_timeout)
|
||||
else:
|
||||
args[1] = max(self._connect_timeout, args[1]/10.0)
|
||||
return super(PatroniSequentialThreadingHandler, self).create_connection(*args, **kwargs)
|
||||
|
||||
|
||||
class ZooKeeper(AbstractDCS):
|
||||
|
||||
def __init__(self, config):
|
||||
super(ZooKeeper, self).__init__(config)
|
||||
|
||||
hosts = config.get('hosts', [])
|
||||
if isinstance(hosts, list):
|
||||
hosts = ','.join(hosts)
|
||||
|
||||
self._client = KazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
|
||||
timeout=config['ttl'], connection_retry={'max_delay': 1, 'max_tries': -1},
|
||||
command_retry={'deadline': config['retry_timeout'], 'max_delay': 1, 'max_tries': -1})
|
||||
self._client.add_listener(self.session_listener)
|
||||
|
||||
self._my_member_data = None
|
||||
self._fetch_cluster = True
|
||||
|
||||
self._orig_kazoo_connect = self._client._connection._connect
|
||||
self._client._connection._connect = self._kazoo_connect
|
||||
|
||||
self._client.start()
|
||||
|
||||
def _kazoo_connect(self, host, port):
|
||||
"""Kazoo is using Ping's to determine health of connection to zookeeper. If there is no
|
||||
response on Ping after Ping interval (1/2 from read_timeout) it will consider current
|
||||
connection dead and try to connect to another node. Without this "magic" it was taking
|
||||
up to 2/3 from session timeout (ttl) to figure out that connection was dead and we had
|
||||
only small time for reconnect and retry.
|
||||
|
||||
This method is needed to return different value of read_timeout, which is not calculated
|
||||
from negotiated session timeout but from value of `loop_wait`. And it is 2 sec smaller
|
||||
than loop_wait, because we can spend up to 2 seconds when calling `touch_member()` and
|
||||
`write_leader_optime()` methods, which also may hang..."""
|
||||
|
||||
ret = self._orig_kazoo_connect(host, port)
|
||||
return max(self.loop_wait - 2, 2)*1000, ret[1]
|
||||
|
||||
def session_listener(self, state):
|
||||
if state in [KazooState.SUSPENDED, KazooState.LOST]:
|
||||
self.cluster_watcher(None)
|
||||
|
||||
def cluster_watcher(self, event):
|
||||
self._fetch_cluster = True
|
||||
self.event.set()
|
||||
|
||||
def reload_config(self, config):
|
||||
self.set_retry_timeout(config['retry_timeout'])
|
||||
|
||||
loop_wait = config['loop_wait']
|
||||
|
||||
loop_wait_changed = self._loop_wait != loop_wait
|
||||
self._loop_wait = loop_wait
|
||||
self._client.handler.set_connect_timeout(loop_wait)
|
||||
|
||||
# We need to reestablish connection to zookeeper if we want to change
|
||||
# read_timeout (and Ping interval respectively), because read_timeout
|
||||
# is calculated in `_kazoo_connect` method. If we are changing ttl at
|
||||
# the same time, set_ttl method will reestablish connection and return
|
||||
# `!True`, otherwise we will close existing connection and let kazoo
|
||||
# open the new one.
|
||||
if not self.set_ttl(int(config['ttl'] * 1000)) and loop_wait_changed:
|
||||
self._client._connection._socket.close()
|
||||
|
||||
def set_ttl(self, ttl):
|
||||
"""It is not possible to change ttl (session_timeout) in zookeeper without
|
||||
destroying old session and creating the new one. This method returns `!True`
|
||||
if session_timeout has been changed (`restart()` has been called)."""
|
||||
if self._client._session_timeout != ttl:
|
||||
self._client._session_timeout = ttl
|
||||
self._client.restart()
|
||||
return True
|
||||
|
||||
def set_retry_timeout(self, retry_timeout):
|
||||
self._client._retry.deadline = retry_timeout
|
||||
|
||||
def get_node(self, key, watch=None):
|
||||
try:
|
||||
ret = self._client.get(key, watch)
|
||||
return (ret[0].decode('utf-8'), ret[1])
|
||||
except NoNodeError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def member(name, value, znode):
|
||||
return Member.from_node(znode.version, name, znode.ephemeralOwner, value)
|
||||
|
||||
def get_children(self, key, watch=None):
|
||||
try:
|
||||
return self._client.get_children(key, watch)
|
||||
except NoNodeError:
|
||||
return []
|
||||
|
||||
def load_members(self, sync_standby):
|
||||
members = []
|
||||
for member in self.get_children(self.members_path, self.cluster_watcher):
|
||||
watch = member == sync_standby and self.cluster_watcher or None
|
||||
data = self.get_node(self.members_path + member, watch)
|
||||
if data is not None:
|
||||
members.append(self.member(member, *data))
|
||||
return members
|
||||
|
||||
def _inner_load_cluster(self):
|
||||
self._fetch_cluster = False
|
||||
self.event.clear()
|
||||
nodes = set(self.get_children(self.client_path(''), self.cluster_watcher))
|
||||
if not nodes:
|
||||
self._fetch_cluster = True
|
||||
|
||||
# get initialize flag
|
||||
initialize = (self.get_node(self.initialize_path) or [None])[0] if self._INITIALIZE in nodes else None
|
||||
|
||||
# get global dynamic configuration
|
||||
config = self.get_node(self.config_path, watch=self.cluster_watcher) if self._CONFIG in nodes else None
|
||||
config = config and ClusterConfig.from_node(config[1].version, config[0], config[1].mzxid)
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = self._OPTIME in nodes and self._fetch_cluster and self.get_node(self.leader_optime_path)
|
||||
last_leader_operation = last_leader_operation and int(last_leader_operation[0]) or 0
|
||||
|
||||
# get synchronization state
|
||||
sync = self.get_node(self.sync_path, watch=self.cluster_watcher) if self._SYNC in nodes else None
|
||||
sync = SyncState.from_node(sync and sync[1].version, sync and sync[0])
|
||||
|
||||
# get list of members
|
||||
sync_standby = sync.leader == self._name and sync.sync_standby or None
|
||||
members = self.load_members(sync_standby) if self._MEMBERS[:-1] in nodes else []
|
||||
|
||||
# get leader
|
||||
leader = self.get_node(self.leader_path) if self._LEADER in nodes else None
|
||||
if leader:
|
||||
client_id = self._client.client_id
|
||||
if not self._ctl and leader[0] == self._name and client_id is not None \
|
||||
and client_id[0] != leader[1].ephemeralOwner:
|
||||
logger.info('I am leader but not owner of the session. Removing leader node')
|
||||
self._client.delete(self.leader_path)
|
||||
leader = None
|
||||
|
||||
if leader:
|
||||
member = Member(-1, leader[0], None, {})
|
||||
member = ([m for m in members if m.name == leader[0]] or [member])[0]
|
||||
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
|
||||
self._fetch_cluster = member.index == -1
|
||||
|
||||
# failover key
|
||||
failover = self.get_node(self.failover_path, watch=self.cluster_watcher) if self._FAILOVER in nodes else None
|
||||
failover = failover and Failover.from_node(failover[1].version, failover[0])
|
||||
|
||||
self._cluster = Cluster(initialize, config, leader, last_leader_operation, members, failover, sync)
|
||||
|
||||
def _load_cluster(self):
|
||||
if self._fetch_cluster or self._cluster is None:
|
||||
try:
|
||||
self._client.retry(self._inner_load_cluster)
|
||||
except Exception:
|
||||
logger.exception('get_cluster')
|
||||
self.cluster_watcher(None)
|
||||
raise ZooKeeperError('ZooKeeper in not responding properly')
|
||||
|
||||
def _create(self, path, value, **kwargs):
|
||||
try:
|
||||
self._client.retry(self._client.create, path, value.encode('utf-8'), **kwargs)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def attempt_to_acquire_leader(self, permanent=False):
|
||||
ret = self._create(self.leader_path, self._name, makepath=True, ephemeral=not permanent)
|
||||
if not ret:
|
||||
logger.info('Could not take out TTL lock')
|
||||
return ret
|
||||
|
||||
def set_failover_value(self, value, index=None):
|
||||
try:
|
||||
self._client.retry(self._client.set, self.failover_path, value.encode('utf-8'), version=index or -1)
|
||||
return True
|
||||
except NoNodeError:
|
||||
return value == '' or (index is None and self._create(self.failover_path, value))
|
||||
except:
|
||||
logging.exception('set_failover_value')
|
||||
return False
|
||||
|
||||
def set_config_value(self, value, index=None):
|
||||
try:
|
||||
self._client.retry(self._client.set, self.config_path, value.encode('utf-8'), version=index or -1)
|
||||
return True
|
||||
except NoNodeError:
|
||||
return index is None and self._create(self.config_path, value)
|
||||
except Exception:
|
||||
logging.exception('set_config_value')
|
||||
return False
|
||||
|
||||
def initialize(self, create_new=True, sysid=""):
|
||||
return self._create(self.initialize_path, sysid, makepath=True) if create_new \
|
||||
else self._client.retry(self._client.set, self.initialize_path, sysid.encode("utf-8"))
|
||||
|
||||
def touch_member(self, data, ttl=None, permanent=False):
|
||||
cluster = self.cluster
|
||||
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
|
||||
data = data.encode('utf-8')
|
||||
if member and self._client.client_id is not None and member.session != self._client.client_id[0]:
|
||||
try:
|
||||
self._client.delete_async(self.member_path).get(timeout=1)
|
||||
except NoNodeError:
|
||||
pass
|
||||
except:
|
||||
return False
|
||||
member = None
|
||||
|
||||
if member:
|
||||
if data == self._my_member_data:
|
||||
return True
|
||||
else:
|
||||
try:
|
||||
self._client.create_async(self.member_path, data, makepath=True, ephemeral=not permanent).get(timeout=1)
|
||||
self._my_member_data = data
|
||||
return True
|
||||
except Exception as e:
|
||||
if not isinstance(e, NodeExistsError):
|
||||
logger.exception('touch_member')
|
||||
return False
|
||||
try:
|
||||
self._client.set_async(self.member_path, data).get(timeout=1)
|
||||
self._my_member_data = data
|
||||
return True
|
||||
except:
|
||||
logger.exception('touch_member')
|
||||
|
||||
return False
|
||||
|
||||
def take_leader(self):
|
||||
return self.attempt_to_acquire_leader()
|
||||
|
||||
def _write_leader_optime(self, last_operation):
|
||||
last_operation = last_operation.encode('utf-8')
|
||||
try:
|
||||
self._client.set_async(self.leader_optime_path, last_operation).get(timeout=1)
|
||||
return True
|
||||
except NoNodeError:
|
||||
try:
|
||||
self._client.create_async(self.leader_optime_path, last_operation, makepath=True).get(timeout=1)
|
||||
return True
|
||||
except:
|
||||
logger.exception('Failed to create %s', self.leader_optime_path)
|
||||
except:
|
||||
logger.exception('Failed to update %s', self.leader_optime_path)
|
||||
return False
|
||||
|
||||
def update_leader(self):
|
||||
return True
|
||||
|
||||
def delete_leader(self):
|
||||
self._client.restart()
|
||||
self._my_member_data = None
|
||||
return True
|
||||
|
||||
def _cancel_initialization(self):
|
||||
node = self.get_node(self.initialize_path)
|
||||
if node:
|
||||
self._client.delete(self.initialize_path, version=node[1].version)
|
||||
|
||||
def cancel_initialization(self):
|
||||
try:
|
||||
self._client.retry(self._cancel_initialization)
|
||||
except:
|
||||
logger.exception("Unable to delete initialize key")
|
||||
|
||||
def delete_cluster(self):
|
||||
try:
|
||||
return self._client.retry(self._client.delete, self.client_path(''), recursive=True)
|
||||
except NoNodeError:
|
||||
return True
|
||||
|
||||
def set_sync_state_value(self, value, index=None):
|
||||
try:
|
||||
self._client.retry(self._client.set, self.sync_path, value.encode('utf-8'), version=index or -1)
|
||||
return True
|
||||
except NoNodeError:
|
||||
return value == '' or (index is None and self._create(self.sync_path, value))
|
||||
except:
|
||||
logging.exception('set_sync_state_value')
|
||||
return False
|
||||
|
||||
def delete_sync_state(self, index=None):
|
||||
return self.set_sync_state_value("{}", index)
|
||||
|
||||
def watch(self, leader_index, timeout):
|
||||
if super(ZooKeeper, self).watch(leader_index, timeout):
|
||||
self._fetch_cluster = True
|
||||
return self._fetch_cluster
|
||||
-282
@@ -1,282 +0,0 @@
|
||||
from __future__ import absolute_import
|
||||
import etcd
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import requests
|
||||
import socket
|
||||
import time
|
||||
import urllib3
|
||||
|
||||
from dns.exception import DNSException
|
||||
from dns import resolver
|
||||
from patroni.dcs import AbstractDCS, Cluster, Failover, Leader, Member
|
||||
from patroni.exceptions import DCSError
|
||||
from patroni.utils import Retry, RetryFailedError, sleep
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EtcdError(DCSError):
|
||||
pass
|
||||
|
||||
|
||||
class Client(etcd.Client):
|
||||
|
||||
def __init__(self, config):
|
||||
super(Client, self).__init__(read_timeout=5)
|
||||
self._config = config
|
||||
self._load_machines_cache()
|
||||
self._allow_reconnect = True
|
||||
|
||||
@property
|
||||
def machines(self):
|
||||
"""Original `machines` method(property) of `etcd.Client` class raise exception
|
||||
when it failed to get list of etcd cluster members. This method is being called
|
||||
only when request failed on one of the etcd members during `api_execute` call.
|
||||
For us it's more important to execute original request rather then get new
|
||||
topology of etcd cluster. So we will catch this exception and return valid list
|
||||
of machines with setting flag `self._update_machines_cache` to `!True`.
|
||||
Later, during next `api_execute` call we will forcefully update machines_cache"""
|
||||
try:
|
||||
ret = super(Client, self).machines
|
||||
random.shuffle(ret)
|
||||
return ret
|
||||
except etcd.EtcdException:
|
||||
if self._update_machines_cache: # We are updating machines_cache
|
||||
raise # This exception is fatal, we should re-raise it.
|
||||
self._update_machines_cache = True
|
||||
return [self._base_uri]
|
||||
|
||||
def api_execute(self, path, method, **kwargs):
|
||||
# Update machines_cache if previous attempt of update has failed
|
||||
self._update_machines_cache and self._load_machines_cache()
|
||||
try:
|
||||
return super(Client, self).api_execute(path, method, **kwargs)
|
||||
except etcd.EtcdConnectionFailed:
|
||||
self._update_machines_cache = True
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_srv_record(host):
|
||||
try:
|
||||
return [(str(r.target).rstrip('.'), r.port) for r in resolver.query('_etcd-server._tcp.' + host, 'SRV')]
|
||||
except DNSException:
|
||||
logger.exception('Can not resolve SRV for %s', host)
|
||||
return []
|
||||
|
||||
# try to workarond bug in python-etcd: https://github.com/jplana/python-etcd/issues/81
|
||||
def _result_from_response(self, response):
|
||||
try:
|
||||
response.data.decode('utf-8')
|
||||
except urllib3.exceptions.TimeoutError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise etcd.EtcdException('Unable to decode server response: %s' % e)
|
||||
return super(Client, self)._result_from_response(response)
|
||||
|
||||
def _get_machines_cache_from_srv(self, discovery_srv):
|
||||
"""Fetch list of etcd-cluster member by resolving _etcd-server._tcp. SRV record.
|
||||
This record should contain list of host and peer ports which could be used to run
|
||||
'GET http://{host}:{port}/members' request (peer protocol)"""
|
||||
|
||||
ret = []
|
||||
for host, port in self.get_srv_record(discovery_srv):
|
||||
url = '{}://{}:{}/members'.format(self._protocol, host, port)
|
||||
try:
|
||||
response = requests.get(url, timeout=5)
|
||||
if response.ok:
|
||||
for member in response.json():
|
||||
ret.extend(member['clientURLs'])
|
||||
break
|
||||
except RequestException:
|
||||
logger.exception('GET %s', url)
|
||||
return list(set(ret))
|
||||
|
||||
def _get_machines_cache_from_dns(self, addr):
|
||||
"""One host might be resolved into multiple ip addresses. We will make list out of it"""
|
||||
|
||||
ret = []
|
||||
host, port = addr.split(':')
|
||||
try:
|
||||
for r in set(socket.getaddrinfo(host, port, socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)):
|
||||
ret.append('{}://{}:{}'.format(self._protocol, r[4][0], r[4][1]))
|
||||
except socket.error:
|
||||
logger.exception('Can not resolve %s', host)
|
||||
return list(set(ret)) if ret else ['{}://{}:{}'.format(self._protocol, host, port)]
|
||||
|
||||
def _load_machines_cache(self):
|
||||
"""This method should fill up `_machines_cache` from scratch.
|
||||
It could happen only in two cases:
|
||||
1. During class initialization
|
||||
2. When all etcd members failed"""
|
||||
|
||||
self._update_machines_cache = True
|
||||
|
||||
if 'discovery_srv' not in self._config and 'host' not in self._config:
|
||||
raise Exception('Neither discovery_srv nor host are defined in etcd section of config')
|
||||
|
||||
self._machines_cache = []
|
||||
|
||||
if 'discovery_srv' in self._config:
|
||||
self._machines_cache = self._get_machines_cache_from_srv(self._config['discovery_srv'])
|
||||
|
||||
if not self._machines_cache and 'host' in self._config:
|
||||
self._machines_cache = self._get_machines_cache_from_dns(self._config['host'])
|
||||
|
||||
# Can not bootstrap list of etcd-cluster members, giving up
|
||||
if not self._machines_cache:
|
||||
raise etcd.EtcdException
|
||||
|
||||
# After filling up initial list of machines_cache we should ask etcd-cluster about actual list
|
||||
self._base_uri = self._machines_cache.pop(0)
|
||||
self._machines_cache = self.machines
|
||||
self._base_uri in self._machines_cache and self._machines_cache.remove(self._base_uri)
|
||||
|
||||
self._update_machines_cache = False
|
||||
|
||||
|
||||
def catch_etcd_errors(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return not func(*args, **kwargs) is None
|
||||
except (RetryFailedError, etcd.EtcdException):
|
||||
return False
|
||||
except:
|
||||
logger.exception("")
|
||||
raise EtcdError("unexpected error")
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class Etcd(AbstractDCS):
|
||||
|
||||
def __init__(self, name, config):
|
||||
super(Etcd, self).__init__(name, config)
|
||||
self.ttl = config.get('ttl', 30)
|
||||
self._retry = Retry(deadline=10, max_delay=1, max_tries=-1,
|
||||
retry_exceptions=(etcd.EtcdConnectionFailed,
|
||||
etcd.EtcdLeaderElectionInProgress,
|
||||
etcd.EtcdWatcherCleared,
|
||||
etcd.EtcdEventIndexCleared))
|
||||
self.client = self.get_etcd_client(config)
|
||||
|
||||
def retry(self, *args, **kwargs):
|
||||
return self._retry.copy()(*args, **kwargs)
|
||||
|
||||
def get_etcd_client(self, config):
|
||||
client = None
|
||||
while not client:
|
||||
try:
|
||||
client = Client(config)
|
||||
except etcd.EtcdException:
|
||||
logger.info('waiting on etcd')
|
||||
sleep(5)
|
||||
return client
|
||||
|
||||
@staticmethod
|
||||
def member(node):
|
||||
return Member.from_node(node.modifiedIndex, os.path.basename(node.key), node.ttl, node.value)
|
||||
|
||||
def _load_cluster(self):
|
||||
try:
|
||||
result = self.retry(self.client.read, self.client_path(''), recursive=True)
|
||||
nodes = {os.path.relpath(node.key, result.key): node for node in result.leaves}
|
||||
|
||||
# get initialize flag
|
||||
initialize = nodes.get(self._INITIALIZE, None)
|
||||
initialize = initialize and initialize.value
|
||||
|
||||
# get last leader operation
|
||||
last_leader_operation = nodes.get(self._LEADER_OPTIME, None)
|
||||
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation.value)
|
||||
|
||||
# get list of members
|
||||
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
|
||||
# get leader
|
||||
leader = nodes.get(self._LEADER, None)
|
||||
if leader:
|
||||
member = Member(-1, leader.value, None, {})
|
||||
member = ([m for m in members if m.name == leader.value] or [member])[0]
|
||||
leader = Leader(leader.modifiedIndex, leader.ttl, member)
|
||||
|
||||
# failover key
|
||||
failover = nodes.get(self._FAILOVER, None)
|
||||
if failover:
|
||||
failover = Failover.from_node(failover.modifiedIndex, failover.value)
|
||||
|
||||
self._cluster = Cluster(initialize, leader, last_leader_operation, members, failover)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
self._cluster = Cluster(False, None, None, [], None)
|
||||
except:
|
||||
logger.exception('get_cluster')
|
||||
raise EtcdError('Etcd is not responding properly')
|
||||
|
||||
@catch_etcd_errors
|
||||
def touch_member(self, connection_string, ttl=None):
|
||||
return self.retry(self.client.set, self.member_path, connection_string, ttl or self.ttl)
|
||||
|
||||
@catch_etcd_errors
|
||||
def take_leader(self):
|
||||
return self.retry(self.client.set, self.leader_path, self._name, self.ttl)
|
||||
|
||||
def attempt_to_acquire_leader(self):
|
||||
try:
|
||||
return bool(self.retry(self.client.write, self.leader_path, self._name, ttl=self.ttl, prevExist=False))
|
||||
except etcd.EtcdAlreadyExist:
|
||||
logger.info('Could not take out TTL lock')
|
||||
except (RetryFailedError, etcd.EtcdException):
|
||||
pass
|
||||
return False
|
||||
|
||||
@catch_etcd_errors
|
||||
def set_failover_value(self, value, index=None):
|
||||
return self.client.write(self.failover_path, value, prevIndex=index or 0)
|
||||
|
||||
@catch_etcd_errors
|
||||
def write_leader_optime(self, last_operation):
|
||||
return self.client.set(self.leader_optime_path, last_operation)
|
||||
|
||||
@catch_etcd_errors
|
||||
def update_leader(self):
|
||||
return self.retry(self.client.test_and_set, self.leader_path, self._name, self._name, self.ttl)
|
||||
|
||||
@catch_etcd_errors
|
||||
def initialize(self, create_new=True, sysid=""):
|
||||
return self.retry(self.client.write, self.initialize_path, sysid, prevExist=(not create_new))
|
||||
|
||||
@catch_etcd_errors
|
||||
def delete_leader(self):
|
||||
return self.client.delete(self.leader_path, prevValue=self._name)
|
||||
|
||||
@catch_etcd_errors
|
||||
def cancel_initialization(self):
|
||||
return self.retry(self.client.delete, self.initialize_path)
|
||||
|
||||
def watch(self, timeout):
|
||||
cluster = self.cluster
|
||||
# watch on leader key changes if it is defined and current node is not lock owner
|
||||
if cluster and cluster.leader and cluster.leader.name != self._name:
|
||||
end_time = time.time() + timeout
|
||||
index = cluster.leader.index
|
||||
|
||||
while index and timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect
|
||||
try:
|
||||
self.client.watch(self.leader_path, index=index + 1, timeout=timeout + 0.5)
|
||||
# Synchronous work of all cluster members with etcd is less expensive
|
||||
# than reestablishing http connection every time from every replica.
|
||||
return True
|
||||
except urllib3.exceptions.TimeoutError:
|
||||
self.client.http.clear()
|
||||
return False
|
||||
except etcd.EtcdException:
|
||||
logging.exception('watch')
|
||||
|
||||
timeout = end_time - time.time()
|
||||
|
||||
try:
|
||||
return super(Etcd, self).watch(timeout)
|
||||
finally:
|
||||
self.event.clear()
|
||||
@@ -13,10 +13,6 @@ class PatroniException(Exception):
|
||||
return repr(self.value)
|
||||
|
||||
|
||||
class PatroniCtlException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PostgresException(PatroniException):
|
||||
pass
|
||||
|
||||
|
||||
+705
-185
File diff suppressed because it is too large
Load Diff
+1032
-343
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import boto.ec2
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AWSConnection:
|
||||
class AWSConnection(object):
|
||||
def __init__(self, cluster_name):
|
||||
self.available = False
|
||||
self.cluster_name = cluster_name if cluster_name is not None else 'unknown'
|
||||
@@ -56,7 +56,7 @@ class AWSConnection:
|
||||
conn = boto.ec2.connect_to_region(self.region)
|
||||
conn.create_tags([self.instance_id], tags)
|
||||
except Exception as e:
|
||||
logger.info("could not set tags for EC2 instance {}: {}".format(self.instance_id, e))
|
||||
logger.info("could not set tags for EC2 instance %s: %s", self.instance_id, e)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -65,8 +65,13 @@ class AWSConnection:
|
||||
return self._tag_ebs(new_role) and ret
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
def main():
|
||||
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO)
|
||||
if len(sys.argv) == 4 and sys.argv[1] in ('on_start', 'on_stop', 'on_role_change'):
|
||||
AWSConnection(cluster_name=sys.argv[3]).on_role_change(sys.argv[2])
|
||||
else:
|
||||
sys.exit("Usage: {0} action role name".format(sys.argv[0]))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# arguments are:
|
||||
# - cluster scope
|
||||
# - cluster role
|
||||
# - master connection string
|
||||
|
||||
# for the AWS, the folliowing environment variables should be defined:
|
||||
# - WALE_ENV_DIR: directory where WAL-E environment is kept
|
||||
# - WAL_S3_BUCKET: a name of the S3 bucket for WAL-E
|
||||
# - WALE_BACKUP_THRESHOLD_MEGABYTES if WAL amount is above that - use pg_basebackup
|
||||
# - WALE_BACKUP_THRESHOLD_PERCENTAGE if WAL size exceeds a certain percentage of the
|
||||
# latest backup size
|
||||
from collections import namedtuple
|
||||
import logging
|
||||
import os
|
||||
import psycopg2
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
if sys.hexversion >= 0x03000000:
|
||||
long = int
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Restore(object):
|
||||
|
||||
def __init__(self, scope, role, datadir, connstring, env=None):
|
||||
self.scope = scope
|
||||
self.role = role
|
||||
self.master_connection = Restore.parse_connstring(connstring)
|
||||
self.data_dir = datadir
|
||||
self.env = os.environ.copy() if not env else env
|
||||
|
||||
@staticmethod
|
||||
def parse_connstring(connstring):
|
||||
# the connection string is in the form host= port= user=
|
||||
# return the dictionary with all components as separare keys
|
||||
result = {}
|
||||
if connstring:
|
||||
for x in connstring.split():
|
||||
if x and '=' in x:
|
||||
key, val = x.split('=')
|
||||
result[key.strip()] = val.strip()
|
||||
return result
|
||||
|
||||
def setup(self):
|
||||
pass
|
||||
|
||||
def replica_method(self):
|
||||
return self.create_replica_with_pg_basebackup
|
||||
|
||||
def replica_fallback_method(self):
|
||||
return None
|
||||
|
||||
def run(self):
|
||||
""" creates a new replica using either pg_basebackup or WAL-E """
|
||||
method_fn = self.replica_method()
|
||||
ret = method_fn() if method_fn else 1
|
||||
if ret != 0 and self.replica_fallback_method() is not None:
|
||||
ret = (self.replica_fallback_method())()
|
||||
return ret
|
||||
|
||||
def create_replica_with_pg_basebackup(self):
|
||||
try:
|
||||
ret = subprocess.call(['pg_basebackup', '-R', '-D',
|
||||
self.data_dir, '--host=' + self.master_connection['host'],
|
||||
'--port=' + str(self.master_connection['port']),
|
||||
'-U', self.master_connection['user']],
|
||||
env=self.env)
|
||||
except Exception as e:
|
||||
logger.error('Error when fetching backup with pg_basebackup: {0}'.format(e))
|
||||
return 1
|
||||
return ret
|
||||
|
||||
|
||||
class WALERestore(Restore):
|
||||
|
||||
def __init__(self, scope, role, datadir, connstring, env=None):
|
||||
super(WALERestore, self).__init__(scope, role, datadir, connstring, env)
|
||||
# check the environment variables
|
||||
self.init_error = False
|
||||
|
||||
def setup(self):
|
||||
if (self.env.get('WAL_S3_BUCKET') and
|
||||
self.env.get('WALE_BACKUP_THRESHOLD_PERCENTAGE') and
|
||||
self.env.get('WALE_BACKUP_THRESHOLD_MEGABYTES')) is None:
|
||||
self.init_error = True
|
||||
else:
|
||||
self.wal_e = namedtuple('WALE',
|
||||
'threshold_megabytes threshold_backup_size_percentage s3_bucket cmd dir env_file')
|
||||
|
||||
self.wal_e.dir = self.env.get('WALE_ENV_DIR', '/home/postgres/etc/wal-e.d/env')
|
||||
self.wal_e.env_file = os.path.join(self.wal_e.dir, 'WALE_S3_PREFIX')
|
||||
|
||||
self.wal_e.cmd = 'envdir {} wal-e --aws-instance-profile '.\
|
||||
format(self.wal_e.dir)
|
||||
self.wal_e.s3_bucket = self.env['WAL_S3_BUCKET']
|
||||
self.wal_e.threshold_megabytes = self.env['WALE_BACKUP_THRESHOLD_MEGABYTES']
|
||||
self.wal_e.threshold_backup_size_percentage = self.env['WALE_BACKUP_THRESHOLD_PERCENTAGE']
|
||||
|
||||
# check that the env file exists, create it otherwise
|
||||
try:
|
||||
if not os.path.exists(self.wal_e.dir):
|
||||
os.makedirs(self.wal_e.dir)
|
||||
# if this is a directory - make sure we have full access there
|
||||
elif not (os.path.isdir(self.wal_e.dir) and os.access(self.wal_e.dir, os.R_OK | os.W_OK | os.X_OK)):
|
||||
logger.error("Unable to access {} or not a directory".format(self.wal_e.dir))
|
||||
self.init_error = True
|
||||
# if WAL_S3_PREFIX is not there - create it and write the full path to bucket
|
||||
if not self.init_error and not os.path.exists(self.wal_e.env_file):
|
||||
with open(self.wal_e.env_file, 'w') as f:
|
||||
f.write("s3://{0}/spilo/{1}/wal/\n".format(self.wal_e.s3_bucket, self.scope))
|
||||
|
||||
except (os.error, IOError) as e:
|
||||
logger.error("{0}: WAL-e archiving is disabled".format(e))
|
||||
self.init_error = True
|
||||
|
||||
def replica_method(self):
|
||||
if self.should_use_s3_to_create_replica():
|
||||
return self.create_replica_with_s3
|
||||
return None
|
||||
|
||||
def replica_fallback_method(self):
|
||||
return self.create_replica_with_pg_basebackup
|
||||
|
||||
def should_use_s3_to_create_replica(self):
|
||||
""" determine whether it makes sense to use S3 and not pg_basebackup """
|
||||
if self.init_error:
|
||||
return False
|
||||
|
||||
threshold_megabytes = self.wal_e.threshold_megabytes
|
||||
threshold_backup_size_percentage = self.wal_e.threshold_backup_size_percentage
|
||||
|
||||
try:
|
||||
latest_backup = subprocess.check_output(self.wal_e.cmd.split() + ['backup-list', '--detail', 'LATEST'],
|
||||
env=self.env)
|
||||
# name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start
|
||||
# wal_segment_backup_stop wal_segment_offset_backup_stop
|
||||
# base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z
|
||||
# 20310671 00000001000000000000007F 00000040
|
||||
# 00000001000000000000007F 00000240
|
||||
backup_strings = latest_backup.splitlines() if latest_backup else ()
|
||||
if len(backup_strings) != 2:
|
||||
return False
|
||||
|
||||
names = backup_strings[0].split()
|
||||
vals = backup_strings[1].split()
|
||||
if (len(names) != len(vals)) or (len(names) != 7):
|
||||
return False
|
||||
|
||||
backup_info = dict(zip(names, vals))
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error("could not query wal-e latest backup: {}".format(e))
|
||||
return False
|
||||
|
||||
try:
|
||||
backup_size = backup_info['expanded_size_bytes']
|
||||
backup_start_segment = backup_info['wal_segment_backup_start']
|
||||
backup_start_offset = backup_info['wal_segment_offset_backup_start']
|
||||
except Exception as e:
|
||||
logger.error("unable to get some of S3 backup parameters: {}".format(e))
|
||||
return False
|
||||
|
||||
# WAL filename is XXXXXXXXYYYYYYYY000000ZZ, where X - timeline, Y - LSN logical log file,
|
||||
# ZZ - 2 high digits of LSN offset. The rest of the offset is the provided decimal offset,
|
||||
# that we have to convert to hex and 'prepend' to the high offset digits.
|
||||
|
||||
lsn_segment = backup_start_segment[8:16]
|
||||
# first 2 characters of the result are 0x and the last one is L
|
||||
lsn_offset = hex((long(backup_start_segment[16:32], 16) << 24) + long(backup_start_offset))[2:-1]
|
||||
|
||||
# construct the LSN from the segment and offset
|
||||
backup_start_lsn = '{}/{}'.format(lsn_segment, lsn_offset)
|
||||
|
||||
conn = None
|
||||
cursor = None
|
||||
diff_in_bytes = long(backup_size)
|
||||
try:
|
||||
# get the difference in bytes between the current WAL location and the backup start offset
|
||||
conn = psycopg2.connect(**(self.master_connection))
|
||||
conn.autocommit = True
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT pg_xlog_location_diff(pg_current_xlog_location(), %s)", (backup_start_lsn,))
|
||||
diff_in_bytes = long(cursor.fetchone()[0])
|
||||
except psycopg2.Error as e:
|
||||
logger.error('could not determine difference with the master location: {}'.format(e))
|
||||
return False
|
||||
finally:
|
||||
cursor and cursor.close()
|
||||
conn and conn.close()
|
||||
|
||||
# if the size of the accumulated WAL segments is more than a certan percentage of the backup size
|
||||
# or exceeds the pre-determined size - pg_basebackup is chosen instead.
|
||||
return (diff_in_bytes < long(threshold_megabytes) * 1048576) and\
|
||||
(diff_in_bytes < long(backup_size) * float(threshold_backup_size_percentage) / 100)
|
||||
|
||||
def create_replica_with_s3(self):
|
||||
if self.init_error:
|
||||
return 1
|
||||
try:
|
||||
ret = subprocess.call(self.wal_e.cmd + ' backup-fetch {} LATEST'.format(self.data_dir), env=self.env)
|
||||
except Exception as e:
|
||||
logger.error('Error when fetching backup with WAL-E: {0}'.format(e))
|
||||
return 1
|
||||
return ret
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) == 5:
|
||||
# scope, role, datadir, connstring
|
||||
restore = WALERestore(*(sys.argv[1:]))
|
||||
restore.setup()
|
||||
sys.exit(restore.run())
|
||||
sys.exit("Usage: {0} scope role datadir connstring".format(sys.argv[0]))
|
||||
Executable
+227
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# sample script to clone new replicas using WAL-E restore
|
||||
# falls back to pg_basebackup if WAL-E restore fails, or if
|
||||
# WAL-E backup is too far behind
|
||||
# note that pg_basebackup still expects to use restore from
|
||||
# WAL-E for transaction logs
|
||||
|
||||
# theoretically should work with SWIFT, but not tested on it
|
||||
|
||||
# arguments are:
|
||||
# - cluster scope
|
||||
# - cluster role
|
||||
# - master connection string
|
||||
# - number of retries
|
||||
# - envdir for the WALE env
|
||||
# - WALE_BACKUP_THRESHOLD_MEGABYTES if WAL amount is above that - use pg_basebackup
|
||||
# - WALE_BACKUP_THRESHOLD_PERCENTAGE if WAL size exceeds a certain percentage of the
|
||||
|
||||
# this script depends on an envdir defining the S3 bucket (or SWIFT dir),and login
|
||||
# credentials per WALE Documentation.
|
||||
|
||||
# currently also requires that you configure the restore_command to use wal_e, example:
|
||||
# recovery_conf:
|
||||
# restore_command: envdir /etc/wal-e.d/env wal-e wal-fetch "%f" "%p" -p 1
|
||||
|
||||
from collections import namedtuple
|
||||
import logging
|
||||
import os
|
||||
import psycopg2
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import argparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RETRY_SLEEP_INTERVAL = 1
|
||||
|
||||
|
||||
# We need to know the current PG version in order to figure out the correct WAL directory name
|
||||
def get_major_version(data_dir):
|
||||
version_file = os.path.join(data_dir, 'PG_VERSION')
|
||||
if os.path.isfile(version_file): # version file exists
|
||||
try:
|
||||
with open(version_file) as f:
|
||||
return float(f.read())
|
||||
except Exception:
|
||||
logger.exception('Failed to read PG_VERSION from %s', data_dir)
|
||||
return 0.0
|
||||
|
||||
|
||||
class WALERestore(object):
|
||||
def __init__(self, scope, datadir, connstring, env_dir, threshold_mb, threshold_pct, use_iam, no_master, retries):
|
||||
self.scope = scope
|
||||
self.master_connection = connstring
|
||||
self.data_dir = datadir
|
||||
self.wal_e = namedtuple('wale', 'dir,threshold_mb,threshold_pct,iam_string,cmd')
|
||||
self.wal_e.dir = env_dir
|
||||
self.wal_e.threshold_mb = threshold_mb
|
||||
self.wal_e.threshold_pct = threshold_pct
|
||||
self.wal_e.iam_string = ' --aws-instance-profile ' if use_iam == 1 else ''
|
||||
self.no_master = no_master
|
||||
self.wal_e.cmd = 'envdir {0} wal-e {1} '.format(self.wal_e.dir, self.wal_e.iam_string)
|
||||
self.init_error = (not os.path.exists(self.wal_e.dir))
|
||||
self.retries = retries
|
||||
|
||||
def run(self):
|
||||
""" creates a new replica using WAL-E """
|
||||
if not self.init_error:
|
||||
try:
|
||||
ret = self.should_use_s3_to_create_replica()
|
||||
if ret:
|
||||
return self.create_replica_with_s3()
|
||||
elif ret is None: # caught an exception, need to retry
|
||||
return 1
|
||||
except Exception:
|
||||
logger.exception("Exception when running WAL-E restore")
|
||||
return 2
|
||||
|
||||
def should_use_s3_to_create_replica(self):
|
||||
""" determine whether it makes sense to use S3 and not pg_basebackup """
|
||||
|
||||
threshold_megabytes = self.wal_e.threshold_mb
|
||||
threshold_backup_size_percentage = self.wal_e.threshold_pct
|
||||
|
||||
try:
|
||||
latest_backup = subprocess.check_output(self.wal_e.cmd.split() + ['backup-list', '--detail', 'LATEST'])
|
||||
# name last_modified expanded_size_bytes wal_segment_backup_start wal_segment_offset_backup_start
|
||||
# wal_segment_backup_stop wal_segment_offset_backup_stop
|
||||
# base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z
|
||||
# 20310671 00000001000000000000007F 00000040
|
||||
# 00000001000000000000007F 00000240
|
||||
backup_strings = latest_backup.decode('utf-8').splitlines() if latest_backup else ()
|
||||
if len(backup_strings) != 2:
|
||||
return False
|
||||
|
||||
names = backup_strings[0].split()
|
||||
vals = backup_strings[1].split()
|
||||
if (len(names) != len(vals)) or (len(names) != 7):
|
||||
return False
|
||||
|
||||
backup_info = dict(zip(names, vals))
|
||||
except subprocess.CalledProcessError:
|
||||
logger.exception("could not query wal-e latest backup")
|
||||
return None
|
||||
|
||||
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:
|
||||
logger.exception("unable to get some of WALE backup parameters")
|
||||
return None
|
||||
|
||||
# WAL filename is XXXXXXXXYYYYYYYY000000ZZ, where X - timeline, Y - LSN logical log file,
|
||||
# ZZ - 2 high digits of LSN offset. The rest of the offset is the provided decimal offset,
|
||||
# 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((int(backup_start_segment[16:32], 16) << 24) + int(backup_start_offset))[2:-1]
|
||||
|
||||
# construct the LSN from the segment and offset
|
||||
backup_start_lsn = '{0}/{1}'.format(lsn_segment, lsn_offset)
|
||||
|
||||
diff_in_bytes = int(backup_size)
|
||||
attempts_no = 0
|
||||
while True:
|
||||
if self.master_connection:
|
||||
try:
|
||||
# get the difference in bytes between the current WAL location and the backup start offset
|
||||
with psycopg2.connect(self.master_connection) as con:
|
||||
con.autocommit = True
|
||||
with con.cursor() as cur:
|
||||
cur.execute("SELECT pg_xlog_location_diff(pg_current_xlog_location(), %s)",
|
||||
(backup_start_lsn,))
|
||||
diff_in_bytes = int(cur.fetchone()[0])
|
||||
except psycopg2.Error:
|
||||
logger.exception('could not determine difference with the master location')
|
||||
if attempts_no < self.retries: # retry in case of a temporarily connection issue
|
||||
attempts_no = attempts_no + 1
|
||||
time.sleep(RETRY_SLEEP_INTERVAL)
|
||||
continue
|
||||
else:
|
||||
if not self.no_master:
|
||||
return False # do no more retries on the outer level
|
||||
logger.info("continue with base backup from S3 since master is not available")
|
||||
diff_in_bytes = 0
|
||||
break
|
||||
else:
|
||||
# always try to use WAL-E if master connection string is not available
|
||||
diff_in_bytes = 0
|
||||
break
|
||||
|
||||
# if the size of the accumulated WAL segments is more than a certan percentage of the backup size
|
||||
# or exceeds the pre-determined size - pg_basebackup is chosen instead.
|
||||
return (diff_in_bytes < int(threshold_megabytes) * 1048576) and\
|
||||
(diff_in_bytes < int(backup_size) * float(threshold_backup_size_percentage) / 100)
|
||||
|
||||
def fix_subdirectory_path_if_broken(self, dirname):
|
||||
# in case it is a symlink pointing to a non-existing location, remove it and create the actual directory
|
||||
path = os.path.join(self.data_dir, dirname)
|
||||
if not os.path.exists(path):
|
||||
if os.path.islink(path): # broken xlog symlink, to remove
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
logger.exception("could not remove broken %s symlink pointing to %s",
|
||||
dirname, os.readlink(path))
|
||||
return False
|
||||
try:
|
||||
os.mkdir(path)
|
||||
except OSError:
|
||||
logger.exception("coud not create missing %s directory path", dirname)
|
||||
return False
|
||||
return True
|
||||
|
||||
def create_replica_with_s3(self):
|
||||
# if we're set up, restore the replica using fetch latest
|
||||
try:
|
||||
ret = subprocess.call(self.wal_e.cmd.split() + ['backup-fetch', '{}'.format(self.data_dir), 'LATEST'])
|
||||
except Exception as e:
|
||||
logger.error('Error when fetching backup with WAL-E: {0}'.format(e))
|
||||
return 1
|
||||
|
||||
if (ret == 0 and not
|
||||
self.fix_subdirectory_path_if_broken('pg_xlog' if get_major_version(self.data_dir) < 10.0 else 'pg_wal')):
|
||||
return 2
|
||||
return ret
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO)
|
||||
parser = argparse.ArgumentParser(description='Script to image replicas using WAL-E')
|
||||
parser.add_argument('--scope', required=True)
|
||||
parser.add_argument('--role', required=False)
|
||||
parser.add_argument('--datadir', required=True)
|
||||
parser.add_argument('--connstring', required=True)
|
||||
parser.add_argument('--retries', type=int, default=1)
|
||||
parser.add_argument('--envdir', required=True)
|
||||
parser.add_argument('--threshold_megabytes', type=int, default=10240)
|
||||
parser.add_argument('--threshold_backup_size_percentage', type=int, default=30)
|
||||
parser.add_argument('--use_iam', type=int, default=0)
|
||||
parser.add_argument('--no_master', type=int, default=0)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Retry cloning in a loop. We do separate retries for the master
|
||||
# connection attempt inside should_use_s3_to_create_replica,
|
||||
# because we need to differentiate between the last attempt and
|
||||
# the rest and make a decision when the last attempt fails on
|
||||
# whether to use WAL-E or not depending on the no_master flag.
|
||||
for _ in range(0, args.retries + 1):
|
||||
restore = WALERestore(scope=args.scope, datadir=args.datadir, connstring=args.connstring,
|
||||
env_dir=args.envdir, threshold_mb=args.threshold_megabytes,
|
||||
threshold_pct=args.threshold_backup_size_percentage, use_iam=args.use_iam,
|
||||
no_master=args.no_master, retries=args.retries)
|
||||
ret = restore.run()
|
||||
if ret != 1: # only WAL-E failures lead to the retry
|
||||
break
|
||||
time.sleep(RETRY_SLEEP_INTERVAL)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+191
-74
@@ -1,95 +1,201 @@
|
||||
import datetime
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import re
|
||||
|
||||
from dateutil import tz
|
||||
from patroni.exceptions import PatroniException
|
||||
|
||||
ignore_sigterm = False
|
||||
interrupted_sleep = False
|
||||
reap_children = False
|
||||
|
||||
_DATE_TIME_RE = re.compile(r'''^
|
||||
(?P<year>\d{4})\-(?P<month>\d{2})\-(?P<day>\d{2}) # date
|
||||
T
|
||||
(?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2})\.(?P<microsecond>\d{6}) # time
|
||||
\d*Z$''', re.X)
|
||||
tzutc = tz.tzutc()
|
||||
|
||||
|
||||
def parse_datetime(time_str):
|
||||
def deep_compare(obj1, obj2):
|
||||
"""
|
||||
>>> parse_datetime('2015-06-10T12:56:30.552539016Z')
|
||||
datetime.datetime(2015, 6, 10, 12, 56, 30, 552539)
|
||||
>>> parse_datetime('2015-06-10 12:56:30.552539016Z')
|
||||
"""
|
||||
m = _DATE_TIME_RE.match(time_str)
|
||||
if not m:
|
||||
return None
|
||||
p = dict((n, int(m.group(n))) for n in 'year month day hour minute second microsecond'.split(' '))
|
||||
return datetime.datetime(**p)
|
||||
|
||||
|
||||
def calculate_ttl(expiration):
|
||||
"""
|
||||
>>> calculate_ttl(None)
|
||||
>>> calculate_ttl('2015-06-10 12:56:30.552539016Z')
|
||||
>>> calculate_ttl('2015-06-10T12:56:30.552539016Z') < 0
|
||||
>>> deep_compare({'1': None}, {})
|
||||
False
|
||||
>>> deep_compare({'1': {}}, {'1': None})
|
||||
False
|
||||
>>> deep_compare({'1': [1]}, {'1': [2]})
|
||||
False
|
||||
>>> deep_compare({'1': 2}, {'1': '2'})
|
||||
True
|
||||
>>> deep_compare({'1': {'2': [3, 4]}}, {'1': {'2': [3, 4]}})
|
||||
True
|
||||
"""
|
||||
if not expiration:
|
||||
return None
|
||||
expiration = parse_datetime(expiration)
|
||||
if not expiration:
|
||||
return None
|
||||
now = datetime.datetime.utcnow()
|
||||
return int((expiration - now).total_seconds())
|
||||
|
||||
if set(list(obj1.keys())) != set(list(obj2.keys())): # Objects have different sets of keys
|
||||
return False
|
||||
|
||||
for key, value in obj1.items():
|
||||
if isinstance(value, dict):
|
||||
if not (isinstance(obj2[key], dict) and deep_compare(value, obj2[key])):
|
||||
return False
|
||||
elif str(value) != str(obj2[key]):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def sigterm_handler(signo, stack_frame):
|
||||
global ignore_sigterm
|
||||
if not ignore_sigterm:
|
||||
ignore_sigterm = True
|
||||
sys.exit()
|
||||
def patch_config(config, data):
|
||||
"""recursively 'patch' `config` with `data`
|
||||
:returns: `!True` if the `config` was changed"""
|
||||
is_changed = False
|
||||
for name, value in data.items():
|
||||
if value is None:
|
||||
if config.pop(name, None) is not None:
|
||||
is_changed = True
|
||||
elif name in config:
|
||||
if isinstance(value, dict):
|
||||
if isinstance(config[name], dict):
|
||||
if patch_config(config[name], value):
|
||||
is_changed = True
|
||||
else:
|
||||
config[name] = value
|
||||
is_changed = True
|
||||
elif str(config[name]) != str(value):
|
||||
config[name] = value
|
||||
is_changed = True
|
||||
else:
|
||||
config[name] = value
|
||||
is_changed = True
|
||||
return is_changed
|
||||
|
||||
|
||||
def sigchld_handler(signo, stack_frame):
|
||||
global interrupted_sleep, reap_children
|
||||
reap_children = interrupted_sleep = True
|
||||
def parse_bool(value):
|
||||
"""
|
||||
>>> parse_bool(1)
|
||||
True
|
||||
>>> parse_bool('off')
|
||||
False
|
||||
>>> parse_bool('foo')
|
||||
"""
|
||||
value = str(value).lower()
|
||||
if value in ('on', 'true', 'yes', '1'):
|
||||
return True
|
||||
if value in ('off', 'false', 'no', '0'):
|
||||
return False
|
||||
|
||||
|
||||
def sleep(interval):
|
||||
global interrupted_sleep
|
||||
current_time = time.time()
|
||||
end_time = current_time + interval
|
||||
while current_time < end_time:
|
||||
interrupted_sleep = False
|
||||
time.sleep(end_time - current_time)
|
||||
if not interrupted_sleep: # we will ignore only sigchld
|
||||
break
|
||||
current_time = time.time()
|
||||
interrupted_sleep = False
|
||||
def strtol(value, strict=True):
|
||||
"""As most as possible close equivalent of strtol(3) function (with base=0),
|
||||
used by postgres to parse parameter values.
|
||||
>>> strtol(0) == (0, '')
|
||||
True
|
||||
>>> strtol(1) == (1, '')
|
||||
True
|
||||
>>> strtol(9) == (9, '')
|
||||
True
|
||||
>>> strtol(' +0x400MB') == (1024, 'MB')
|
||||
True
|
||||
>>> strtol(' -070d') == (-56, 'd')
|
||||
True
|
||||
>>> strtol(' d ') == (None, 'd')
|
||||
True
|
||||
>>> strtol('9s', False) == (9, 's')
|
||||
True
|
||||
>>> strtol(' s ', False) == (1, 's')
|
||||
True
|
||||
"""
|
||||
value = str(value).strip()
|
||||
l = len(value)
|
||||
i = 0
|
||||
# skip sign:
|
||||
if i < l and value[i] in ('-', '+'):
|
||||
i += 1
|
||||
|
||||
# we always expect to get digit in the beginning
|
||||
if i < l and value[i].isdigit():
|
||||
if value[i] == '0':
|
||||
i += 1
|
||||
if i < l and value[i] in ('x', 'X'): # '0' followed by 'x': HEX
|
||||
base = 16
|
||||
i += 1
|
||||
else: # just starts with '0': OCT
|
||||
base = 8
|
||||
else: # any other digit: DEC
|
||||
base = 10
|
||||
|
||||
ret = None
|
||||
while i <= l:
|
||||
try: # try to find maximally long number
|
||||
i += 1 # by giving to `int` longer and longer strings
|
||||
ret = int(value[:i], base)
|
||||
except ValueError: # until we will not get an exception or end of the string
|
||||
i -= 1
|
||||
break
|
||||
if ret is not None: # yay! there is a number in the beginning of the string
|
||||
return ret, value[i:].strip() # return the number and the "rest"
|
||||
|
||||
return (None if strict else 1), value.strip()
|
||||
|
||||
|
||||
def setup_signal_handlers():
|
||||
signal.signal(signal.SIGTERM, sigterm_handler)
|
||||
signal.signal(signal.SIGCHLD, sigchld_handler)
|
||||
def parse_int(value, base_unit=None):
|
||||
"""
|
||||
>>> parse_int('1') == 1
|
||||
True
|
||||
>>> parse_int(' 0x400 MB ', '16384kB') == 64
|
||||
True
|
||||
>>> parse_int('1MB', 'kB') == 1024
|
||||
True
|
||||
>>> parse_int('1000 ms', 's') == 1
|
||||
True
|
||||
>>> parse_int('1GB', 'MB') is None
|
||||
True
|
||||
>>> parse_int(0) == 0
|
||||
True
|
||||
"""
|
||||
|
||||
convert = {
|
||||
'kB': {'kB': 1, 'MB': 1024, 'GB': 1024 * 1024, 'TB': 1024 * 1024 * 1024},
|
||||
'ms': {'ms': 1, 's': 1000, 'min': 1000 * 60, 'h': 1000 * 60 * 60, 'd': 1000 * 60 * 60 * 24},
|
||||
's': {'ms': -1000, 's': 1, 'min': 60, 'h': 60 * 60, 'd': 60 * 60 * 24},
|
||||
'min': {'ms': -1000 * 60, 's': -60, 'min': 1, 'h': 60, 'd': 60 * 24}
|
||||
}
|
||||
|
||||
value, unit = strtol(value)
|
||||
if value is not None:
|
||||
if not unit:
|
||||
return value
|
||||
|
||||
if base_unit and base_unit not in convert:
|
||||
base_value, base_unit = strtol(base_unit, False)
|
||||
else:
|
||||
base_value = 1
|
||||
if base_unit in convert and unit in convert[base_unit]:
|
||||
multiplier = convert[base_unit][unit]
|
||||
if multiplier < 0:
|
||||
value /= -multiplier
|
||||
else:
|
||||
value *= multiplier
|
||||
return int(value/base_value)
|
||||
|
||||
|
||||
def reap_children():
|
||||
global reap_children
|
||||
if reap_children:
|
||||
try:
|
||||
while True:
|
||||
ret = os.waitpid(-1, os.WNOHANG)
|
||||
if ret == (0, 0):
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
reap_children = False
|
||||
def compare_values(vartype, unit, old_value, new_value):
|
||||
"""
|
||||
>>> compare_values('enum', None, 'remote_write', 'REMOTE_WRITE')
|
||||
True
|
||||
>>> compare_values('real', None, '1.23', 1.23)
|
||||
True
|
||||
"""
|
||||
|
||||
# if the integer or bool new_value is not correct this function will return False
|
||||
if vartype == 'bool':
|
||||
old_value = parse_bool(old_value)
|
||||
new_value = parse_bool(new_value)
|
||||
elif vartype == 'integer':
|
||||
old_value = parse_int(old_value)
|
||||
new_value = parse_int(new_value, unit)
|
||||
elif vartype == 'enum':
|
||||
return str(old_value).lower() == str(new_value).lower()
|
||||
else: # ('string', 'real')
|
||||
return str(old_value) == str(new_value)
|
||||
return old_value is not None and new_value is not None and old_value == new_value
|
||||
|
||||
|
||||
def _sleep(interval):
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def is_valid_pg_version(version):
|
||||
return re.match(r'[1-9][0-9]?(\.(0|([1-9][0-9]?))){2}$', version)
|
||||
|
||||
|
||||
class RetryFailedError(PatroniException):
|
||||
@@ -97,12 +203,12 @@ class RetryFailedError(PatroniException):
|
||||
"""Raised when retrying an operation ultimately failed, after retrying the maximum number of attempts."""
|
||||
|
||||
|
||||
class Retry:
|
||||
class Retry(object):
|
||||
|
||||
"""Helper for retrying a method in the face of retry-able exceptions"""
|
||||
|
||||
def __init__(self, max_tries=1, delay=0.1, backoff=2, max_jitter=0.8, max_delay=3600,
|
||||
sleep_func=sleep, deadline=None, retry_exceptions=PatroniException):
|
||||
sleep_func=_sleep, deadline=None, retry_exceptions=PatroniException):
|
||||
"""Create a :class:`Retry` instance for retrying function calls
|
||||
|
||||
:param max_tries: How many times to retry the command. -1 means infinite tries.
|
||||
@@ -163,3 +269,14 @@ class Retry:
|
||||
else:
|
||||
self.sleep_func(sleeptime)
|
||||
self._cur_delay = min(self._cur_delay * self.backoff, self.max_delay)
|
||||
|
||||
|
||||
def polling_loop(timeout, interval=1):
|
||||
"""Returns an iterator that returns values until timeout has passed. Timeout is measured from start of iteration."""
|
||||
start_time = time.time()
|
||||
iteration = 0
|
||||
end_time = start_time + timeout
|
||||
while time.time() < end_time:
|
||||
yield iteration
|
||||
iteration += 1
|
||||
time.sleep(interval)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = '0.7'
|
||||
__version__ = '1.2.3'
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
import logging
|
||||
import random
|
||||
import requests
|
||||
import time
|
||||
|
||||
from kazoo.client import KazooClient, KazooState
|
||||
from kazoo.exceptions import NoNodeError, NodeExistsError
|
||||
from patroni.dcs import AbstractDCS, Cluster, Failover, Leader, Member
|
||||
from patroni.exceptions import DCSError
|
||||
from patroni.utils import sleep
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZooKeeperError(DCSError):
|
||||
pass
|
||||
|
||||
|
||||
class ExhibitorEnsembleProvider:
|
||||
|
||||
TIMEOUT = 3.1
|
||||
|
||||
def __init__(self, hosts, port, uri_path='/exhibitor/v1/cluster/list', poll_interval=300):
|
||||
self._exhibitor_port = port
|
||||
self._uri_path = uri_path
|
||||
self._poll_interval = poll_interval
|
||||
self._exhibitors = hosts
|
||||
self._master_exhibitors = hosts
|
||||
self._zookeeper_hosts = ''
|
||||
self._next_poll = None
|
||||
while not self.poll():
|
||||
logger.info('waiting on exhibitor')
|
||||
sleep(5)
|
||||
|
||||
def poll(self):
|
||||
if self._next_poll and self._next_poll > time.time():
|
||||
return False
|
||||
|
||||
json = self._query_exhibitors(self._exhibitors)
|
||||
if not json:
|
||||
json = self._query_exhibitors(self._master_exhibitors)
|
||||
|
||||
if isinstance(json, dict) and 'servers' in json and 'port' in json:
|
||||
self._next_poll = time.time() + self._poll_interval
|
||||
zookeeper_hosts = ','.join([h + ':' + str(json['port']) for h in sorted(json['servers'])])
|
||||
if self._zookeeper_hosts != zookeeper_hosts:
|
||||
logger.info('ZooKeeper connection string has changed: %s => %s', self._zookeeper_hosts, zookeeper_hosts)
|
||||
self._zookeeper_hosts = zookeeper_hosts
|
||||
self._exhibitors = json['servers']
|
||||
return True
|
||||
return False
|
||||
|
||||
def _query_exhibitors(self, exhibitors):
|
||||
random.shuffle(exhibitors)
|
||||
for host in exhibitors:
|
||||
uri = 'http://{}:{}{}'.format(host, self._exhibitor_port, self._uri_path)
|
||||
try:
|
||||
response = requests.get(uri, timeout=self.TIMEOUT)
|
||||
return response.json()
|
||||
except RequestException:
|
||||
pass
|
||||
return None
|
||||
|
||||
@property
|
||||
def zookeeper_hosts(self):
|
||||
return self._zookeeper_hosts
|
||||
|
||||
|
||||
class ZooKeeper(AbstractDCS):
|
||||
|
||||
def __init__(self, name, config):
|
||||
super(ZooKeeper, self).__init__(name, config)
|
||||
|
||||
hosts = config.get('hosts', [])
|
||||
if isinstance(hosts, list):
|
||||
hosts = ','.join(hosts)
|
||||
|
||||
self.exhibitor = None
|
||||
if 'exhibitor' in config:
|
||||
exhibitor = config['exhibitor']
|
||||
interval = exhibitor.get('poll_interval', 300)
|
||||
self.exhibitor = ExhibitorEnsembleProvider(exhibitor['hosts'], exhibitor['port'], poll_interval=interval)
|
||||
hosts = self.exhibitor.zookeeper_hosts
|
||||
|
||||
self.client = KazooClient(hosts=hosts,
|
||||
timeout=(config.get('session_timeout', None) or 30),
|
||||
command_retry={
|
||||
'deadline': (config.get('reconnect_timeout', None) or 10),
|
||||
'max_delay': 1,
|
||||
'max_tries': -1},
|
||||
connection_retry={'max_delay': 1, 'max_tries': -1})
|
||||
self.client.add_listener(self.session_listener)
|
||||
|
||||
self._my_member_data = None
|
||||
self.fetch_cluster = True
|
||||
self.last_leader_operation = 0
|
||||
|
||||
self.client.start(None)
|
||||
|
||||
def session_listener(self, state):
|
||||
if state in [KazooState.SUSPENDED, KazooState.LOST]:
|
||||
self.cluster_watcher(None)
|
||||
|
||||
def cluster_watcher(self, event):
|
||||
self.fetch_cluster = True
|
||||
self.event.set()
|
||||
|
||||
def get_node(self, key, watch=None):
|
||||
try:
|
||||
ret = self.client.get(key, watch)
|
||||
return (ret[0].decode('utf-8'), ret[1])
|
||||
except NoNodeError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def member(name, value, znode):
|
||||
return Member.from_node(znode.version, name, znode.ephemeralOwner, value)
|
||||
|
||||
def get_children(self, key, watch=None):
|
||||
try:
|
||||
return self.client.get_children(key, watch)
|
||||
except NoNodeError:
|
||||
return []
|
||||
|
||||
def load_members(self):
|
||||
members = []
|
||||
for member in self.get_children(self.members_path, self.cluster_watcher):
|
||||
data = self.get_node(self.members_path + member)
|
||||
if data is not None:
|
||||
members.append(self.member(member, *data))
|
||||
return members
|
||||
|
||||
def _inner_load_cluster(self):
|
||||
self.fetch_cluster = False
|
||||
self.event.clear()
|
||||
nodes = set(self.get_children(self.client_path(''), self.cluster_watcher))
|
||||
if not nodes:
|
||||
self.fetch_cluster = True
|
||||
|
||||
# get initialize flag
|
||||
initialize = (self.get_node(self.initialize_path) or [None])[0] if self._INITIALIZE in nodes else None
|
||||
|
||||
# get list of members
|
||||
members = self.load_members() if self._MEMBERS[:-1] in nodes else []
|
||||
|
||||
# get leader
|
||||
leader = self.get_node(self.leader_path) if self._LEADER in nodes else None
|
||||
if leader:
|
||||
client_id = self.client.client_id
|
||||
if leader[0] == self._name and client_id is not None and client_id[0] != leader[1].ephemeralOwner:
|
||||
logger.info('I am leader but not owner of the session. Removing leader node')
|
||||
self.client.delete(self.leader_path)
|
||||
leader = None
|
||||
|
||||
if leader:
|
||||
member = Member(-1, leader[0], None, {})
|
||||
member = ([m for m in members if m.name == leader[0]] or [member])[0]
|
||||
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
|
||||
self.fetch_cluster = member.index == -1
|
||||
|
||||
# failover key
|
||||
failover = self.get_node(self.failover_path, watch=self.cluster_watcher) if self._FAILOVER in nodes else None
|
||||
if failover:
|
||||
failover = Failover.from_node(failover[1].version, failover[0])
|
||||
|
||||
# get last leader operation
|
||||
optime = self.get_node(self.leader_optime_path) if self._OPTIME in nodes and self.fetch_cluster else None
|
||||
self.last_leader_operation = 0 if optime is None else int(optime[0])
|
||||
self._cluster = Cluster(initialize, leader, self.last_leader_operation, members, failover)
|
||||
|
||||
def _load_cluster(self):
|
||||
if self.exhibitor and self.exhibitor.poll():
|
||||
self.client.set_hosts(self.exhibitor.zookeeper_hosts)
|
||||
|
||||
if self.fetch_cluster:
|
||||
try:
|
||||
self.client.retry(self._inner_load_cluster)
|
||||
except:
|
||||
logger.exception('get_cluster')
|
||||
self.session_listener(KazooState.LOST)
|
||||
raise ZooKeeperError('ZooKeeper in not responding properly')
|
||||
|
||||
def _create(self, path, value, **kwargs):
|
||||
try:
|
||||
self.client.retry(self.client.create, path, value.encode('utf-8'), **kwargs)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def attempt_to_acquire_leader(self):
|
||||
ret = self._create(self.leader_path, self._name, makepath=True, ephemeral=True)
|
||||
ret or logger.info('Could not take out TTL lock')
|
||||
return ret
|
||||
|
||||
def set_failover_value(self, value, index=None):
|
||||
try:
|
||||
self.client.retry(self.client.set, self.failover_path, value.encode('utf-8'), version=index or -1)
|
||||
return True
|
||||
except NoNodeError:
|
||||
return value == '' or (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
|
||||
except NodeExistsError:
|
||||
try:
|
||||
self.client.retry(self.client.set, path, data)
|
||||
self._my_member_data = data
|
||||
return True
|
||||
except:
|
||||
logger.exception('touch_member')
|
||||
except:
|
||||
logger.exception('touch_member')
|
||||
return False
|
||||
|
||||
def take_leader(self):
|
||||
return self.attempt_to_acquire_leader()
|
||||
|
||||
def write_leader_optime(self, last_operation):
|
||||
last_operation = last_operation.encode('utf-8')
|
||||
if last_operation != self.last_leader_operation:
|
||||
self.last_leader_operation = last_operation
|
||||
path = self.leader_optime_path
|
||||
try:
|
||||
self.client.retry(self.client.set, path, last_operation)
|
||||
except NoNodeError:
|
||||
try:
|
||||
self.client.retry(self.client.create, path, last_operation, makepath=True)
|
||||
except:
|
||||
logger.exception('Failed to create %s', path)
|
||||
except:
|
||||
logger.exception('Failed to update %s', path)
|
||||
|
||||
def update_leader(self):
|
||||
return True
|
||||
|
||||
def delete_leader(self):
|
||||
self.client.restart()
|
||||
self._my_member_data = None
|
||||
return True
|
||||
|
||||
def _cancel_initialization(self):
|
||||
node = self.get_node(self.initialize_path)
|
||||
if node:
|
||||
self.client.delete(self.initialize_path, version=node[1].version)
|
||||
|
||||
def cancel_initialization(self):
|
||||
try:
|
||||
self.client.retry(self._cancel_initialization)
|
||||
except:
|
||||
logger.exception("Unable to delete initialize key")
|
||||
|
||||
def watch(self, timeout):
|
||||
if super(ZooKeeper, self).watch(timeout):
|
||||
self.fetch_cluster = True
|
||||
return self.fetch_cluster
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
from patroni.ctl import ctl
|
||||
|
||||
if __name__ == '__main__':
|
||||
ctl()
|
||||
ctl(None)
|
||||
|
||||
Binary file not shown.
+70
-79
@@ -1,92 +1,83 @@
|
||||
ttl: &ttl 30
|
||||
loop_wait: &loop_wait 10
|
||||
scope: &scope batman
|
||||
scope: batman
|
||||
#namespace: /service/
|
||||
name: postgresql0
|
||||
|
||||
restapi:
|
||||
listen: 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
|
||||
# authentication:
|
||||
# username: username
|
||||
# password: password
|
||||
|
||||
etcd:
|
||||
scope: *scope
|
||||
ttl: *ttl
|
||||
host: 127.0.0.1:4001
|
||||
#discovery_srv: my-etcd.domain
|
||||
#zookeeper:
|
||||
# scope: *scope
|
||||
# session_timeout: *ttl
|
||||
# reconnect_timeout: *loop_wait
|
||||
# hosts:
|
||||
# - 127.0.0.1:2181
|
||||
# - 127.0.0.2:2181
|
||||
# exhibitor:
|
||||
# poll_interval: 300
|
||||
# port: 8181
|
||||
# hosts:
|
||||
# - host1
|
||||
# - host2
|
||||
# - host3
|
||||
host: 127.0.0.1:2379
|
||||
|
||||
bootstrap:
|
||||
# this section will be written into Etcd:/<namespace>/<scope>/config after initializing new cluster
|
||||
# and all other cluster members will use it as a `global configuration`
|
||||
dcs:
|
||||
ttl: 30
|
||||
loop_wait: 10
|
||||
retry_timeout: 10
|
||||
maximum_lag_on_failover: 1048576
|
||||
# master_start_timeout: 300
|
||||
# synchronous_mode: false
|
||||
postgresql:
|
||||
use_pg_rewind: true
|
||||
# use_slots: true
|
||||
parameters:
|
||||
# wal_level: hot_standby
|
||||
# hot_standby: "on"
|
||||
# wal_keep_segments: 8
|
||||
# max_wal_senders: 5
|
||||
# max_replication_slots: 5
|
||||
# wal_log_hints: "on"
|
||||
# archive_mode: "on"
|
||||
# archive_timeout: 1800s
|
||||
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
|
||||
# recovery_conf:
|
||||
# restore_command: cp ../wal_archive/%f %p
|
||||
|
||||
# some desired options for 'initdb'
|
||||
initdb: # Note: It needs to be a list (some options need values, others are switches)
|
||||
- encoding: UTF8
|
||||
- data-checksums
|
||||
|
||||
pg_hba: # Add following lines to pg_hba.conf after running 'initdb'
|
||||
- host replication replicator 127.0.0.1/32 md5
|
||||
- host all all 0.0.0.0/0 md5
|
||||
# - hostssl all all 0.0.0.0/0 md5
|
||||
|
||||
# Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter)
|
||||
# post_init: /usr/local/bin/setup_cluster.sh
|
||||
|
||||
# Some additional users users which needs to be created after initializing new cluster
|
||||
users:
|
||||
admin:
|
||||
password: admin
|
||||
options:
|
||||
- createrole
|
||||
- createdb
|
||||
|
||||
postgresql:
|
||||
name: postgresql0
|
||||
scope: *scope
|
||||
listen: 127.0.0.1:5432
|
||||
connect_address: 127.0.0.1:5432
|
||||
data_dir: data/postgresql0
|
||||
maximum_lag_on_failover: 1048576 # 1 megabyte in bytes
|
||||
use_slots: True
|
||||
# bin_dir:
|
||||
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:
|
||||
- host all all 0.0.0.0/0 md5
|
||||
- hostssl all all 0.0.0.0/0 md5
|
||||
replication:
|
||||
username: replicator
|
||||
password: rep-pass
|
||||
network: 127.0.0.1/32
|
||||
superuser:
|
||||
username: postgres
|
||||
password: zalando
|
||||
admin:
|
||||
username: admin
|
||||
password: admin
|
||||
wal_e:
|
||||
env_dir: /home/postgres/etc/wal-e.d/env
|
||||
threshold_megabytes: 10240
|
||||
threshold_backup_size_percentage: 30
|
||||
restore: patroni/scripts/restore.py
|
||||
#recovery_conf:
|
||||
#restore_command: cp ../wal_archive/%f %p
|
||||
authentication:
|
||||
replication:
|
||||
username: replicator
|
||||
password: rep-pass
|
||||
superuser:
|
||||
username: postgres
|
||||
password: zalando
|
||||
parameters:
|
||||
archive_mode: "on"
|
||||
wal_level: hot_standby
|
||||
archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f
|
||||
max_wal_senders: 5
|
||||
wal_keep_segments: 8
|
||||
archive_timeout: 1800s
|
||||
max_replication_slots: 5
|
||||
hot_standby: "on"
|
||||
wal_log_hints: "on"
|
||||
unix_socket_directories: '.'
|
||||
tags:
|
||||
nofailover: False
|
||||
noloadbalance: False
|
||||
clonefrom: False
|
||||
replicatefrom: 127.0.0.1
|
||||
nofailover: false
|
||||
noloadbalance: false
|
||||
clonefrom: false
|
||||
nosync: false
|
||||
|
||||
+67
-79
@@ -1,92 +1,80 @@
|
||||
ttl: &ttl 30
|
||||
loop_wait: &loop_wait 10
|
||||
scope: &scope batman
|
||||
scope: batman
|
||||
#namespace: /service/
|
||||
name: postgresql1
|
||||
|
||||
restapi:
|
||||
listen: 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
|
||||
# authentication:
|
||||
# username: username
|
||||
# password: password
|
||||
|
||||
etcd:
|
||||
scope: *scope
|
||||
ttl: *ttl
|
||||
host: 127.0.0.1:4001
|
||||
#discovery_srv: my-etcd.domain
|
||||
#zookeeper:
|
||||
# scope: *scope
|
||||
# session_timeout: *ttl
|
||||
# reconnect_timeout: *loop_wait
|
||||
# hosts:
|
||||
# - 127.0.0.1:2181
|
||||
# - 127.0.0.2:2181
|
||||
# exhibitor:
|
||||
# poll_interval: 300
|
||||
# port: 8181
|
||||
# hosts:
|
||||
# - host1
|
||||
# - host2
|
||||
# - host3
|
||||
host: 127.0.0.1:2379
|
||||
|
||||
bootstrap:
|
||||
# this section will be written into Etcd:/<namespace>/<scope>/config after initializing new cluster
|
||||
# and all other cluster members will use it as a `global configuration`
|
||||
dcs:
|
||||
ttl: 30
|
||||
loop_wait: 10
|
||||
retry_timeout: 10
|
||||
maximum_lag_on_failover: 1048576
|
||||
postgresql:
|
||||
use_pg_rewind: true
|
||||
# use_slots: true
|
||||
parameters:
|
||||
# wal_level: hot_standby
|
||||
# hot_standby: "on"
|
||||
# wal_keep_segments: 8
|
||||
# max_wal_senders: 5
|
||||
# max_replication_slots: 5
|
||||
# wal_log_hints: "on"
|
||||
# archive_mode: "on"
|
||||
# archive_timeout: 1800s
|
||||
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
|
||||
# recovery_conf:
|
||||
# restore_command: cp ../wal_archive/%f %p
|
||||
|
||||
# some desired options for 'initdb'
|
||||
initdb: # Note: It needs to be a list (some options need values, others are switches)
|
||||
- encoding: UTF8
|
||||
- data-checksums
|
||||
|
||||
pg_hba: # Add following lines to pg_hba.conf after running 'initdb'
|
||||
- host replication replicator 127.0.0.1/32 md5
|
||||
- host all all 0.0.0.0/0 md5
|
||||
# - hostssl all all 0.0.0.0/0 md5
|
||||
|
||||
# Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter)
|
||||
# post_init: /usr/local/bin/setup_cluster.sh
|
||||
|
||||
# Some additional users users which needs to be created after initializing new cluster
|
||||
users:
|
||||
admin:
|
||||
password: admin
|
||||
options:
|
||||
- createrole
|
||||
- createdb
|
||||
|
||||
postgresql:
|
||||
name: postgresql1
|
||||
scope: *scope
|
||||
listen: 127.0.0.1:5433
|
||||
connect_address: 127.0.0.1:5433
|
||||
data_dir: data/postgresql1
|
||||
maximum_lag_on_failover: 1048576 # 1 megabyte in bytes
|
||||
use_slots: True
|
||||
# bin_dir:
|
||||
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:
|
||||
- host all all 0.0.0.0/0 md5
|
||||
- hostssl all all 0.0.0.0/0 md5
|
||||
replication:
|
||||
username: replicator
|
||||
password: rep-pass
|
||||
network: 127.0.0.1/32
|
||||
superuser:
|
||||
user: postgres
|
||||
password: zalando
|
||||
admin:
|
||||
username: admin
|
||||
password: admin
|
||||
#recovery_conf:
|
||||
#restore_command: cp ../wal_archive/%f %p
|
||||
wal_e:
|
||||
env_dir: /home/postgres/etc/wal-e.d/env
|
||||
threshold_megabytes: 10240
|
||||
threshold_backup_size_percentage: 30
|
||||
restore: patroni/scripts/restore.py
|
||||
authentication:
|
||||
replication:
|
||||
username: replicator
|
||||
password: rep-pass
|
||||
superuser:
|
||||
username: postgres
|
||||
password: zalando
|
||||
parameters:
|
||||
archive_mode: "on"
|
||||
wal_level: hot_standby
|
||||
archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f
|
||||
max_wal_senders: 5
|
||||
wal_keep_segments: 8
|
||||
archive_timeout: 1800s
|
||||
max_replication_slots: 5
|
||||
hot_standby: "on"
|
||||
wal_log_hints: "on"
|
||||
unix_socket_directories: '.'
|
||||
tags:
|
||||
nofailover: False
|
||||
noloadbalance: False
|
||||
clonefrom: False
|
||||
replicatefrom: 127.0.0.1
|
||||
nofailover: false
|
||||
noloadbalance: false
|
||||
clonefrom: false
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
scope: batman
|
||||
#namespace: /service/
|
||||
name: postgresql2
|
||||
|
||||
restapi:
|
||||
listen: 127.0.0.1:8010
|
||||
connect_address: 127.0.0.1:8010
|
||||
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
# keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
|
||||
authentication:
|
||||
username: username
|
||||
password: password
|
||||
|
||||
etcd:
|
||||
host: 127.0.0.1:2379
|
||||
|
||||
bootstrap:
|
||||
# this section will be written into Etcd:/<namespace>/<scope>/config after initializing new cluster
|
||||
# and all other cluster members will use it as a `global configuration`
|
||||
dcs:
|
||||
ttl: 30
|
||||
loop_wait: 10
|
||||
retry_timeout: 10
|
||||
maximum_lag_on_failover: 1048576
|
||||
postgresql:
|
||||
use_pg_rewind: true
|
||||
# use_slots: true
|
||||
parameters:
|
||||
# wal_level: hot_standby
|
||||
# hot_standby: "on"
|
||||
# wal_keep_segments: 8
|
||||
# max_wal_senders: 5
|
||||
# max_replication_slots: 5
|
||||
# wal_log_hints: "on"
|
||||
# archive_mode: "on"
|
||||
# archive_timeout: 1800s
|
||||
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
|
||||
# recovery_conf:
|
||||
# restore_command: cp ../wal_archive/%f %p
|
||||
|
||||
# some desired options for 'initdb'
|
||||
initdb: # Note: It needs to be a list (some options need values, others are switches)
|
||||
- encoding: UTF8
|
||||
- data-checksums
|
||||
|
||||
pg_hba: # Add following lines to pg_hba.conf after running 'initdb'
|
||||
- host replication replicator 127.0.0.1/32 md5
|
||||
- host all all 0.0.0.0/0 md5
|
||||
# - hostssl all all 0.0.0.0/0 md5
|
||||
|
||||
# Some additional users users which needs to be created after initializing new cluster
|
||||
users:
|
||||
admin:
|
||||
password: admin
|
||||
options:
|
||||
- createrole
|
||||
- createdb
|
||||
|
||||
postgresql:
|
||||
listen: 127.0.0.1:5434
|
||||
connect_address: 127.0.0.1:5434
|
||||
data_dir: data/postgresql2
|
||||
# bin_dir:
|
||||
pgpass: /tmp/pgpass2
|
||||
authentication:
|
||||
replication:
|
||||
username: replicator
|
||||
password: rep-pass
|
||||
superuser:
|
||||
username: postgres
|
||||
password: zalando
|
||||
parameters:
|
||||
unix_socket_directories: '.'
|
||||
tags:
|
||||
nofailover: false
|
||||
noloadbalance: false
|
||||
clonefrom: false
|
||||
replicatefrom: postgres1
|
||||
@@ -1,11 +0,0 @@
|
||||
boto
|
||||
dnspython
|
||||
mock
|
||||
psycopg2>=2.6.1
|
||||
PyYAML
|
||||
requests
|
||||
six >= 1.7
|
||||
kazoo>=2.2.1
|
||||
python-etcd>=0.4.1
|
||||
click>=4.1
|
||||
prettytable>=0.7
|
||||
@@ -1,11 +0,0 @@
|
||||
boto
|
||||
mock
|
||||
dnspython3
|
||||
psycopg2>=2.6.1
|
||||
PyYAML
|
||||
requests
|
||||
six
|
||||
kazoo>=2.2.1
|
||||
python-etcd>=0.4.1
|
||||
click>=4.1
|
||||
prettytable>=0.7
|
||||
@@ -0,0 +1,13 @@
|
||||
urllib3>=1.9
|
||||
boto
|
||||
psycopg2>=2.6.1
|
||||
PyYAML
|
||||
requests
|
||||
six >= 1.7
|
||||
kazoo==2.2.1
|
||||
python-etcd==0.4.3
|
||||
python-consul==0.7.0
|
||||
click>=4.1
|
||||
prettytable>=0.7
|
||||
tzlocal
|
||||
python-dateutil
|
||||
@@ -4,13 +4,12 @@
|
||||
Setup file for patroni
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
|
||||
import setuptools
|
||||
from setuptools.command.test import test as TestCommand
|
||||
from setuptools import setup
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
if sys.version_info < (2, 7, 0):
|
||||
sys.stderr.write('FATAL: patroni needs to be run with Python 2.7+\n')
|
||||
@@ -25,6 +24,7 @@ def read_version(package):
|
||||
exec(fd.read(), data)
|
||||
return data['__version__']
|
||||
|
||||
|
||||
NAME = 'patroni'
|
||||
MAIN_PACKAGE = NAME
|
||||
SCRIPTS = 'scripts'
|
||||
@@ -32,9 +32,9 @@ VERSION = read_version(MAIN_PACKAGE)
|
||||
DESCRIPTION = 'PostgreSQL High-Available orchestrator and CLI'
|
||||
LICENSE = 'The MIT License'
|
||||
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]'
|
||||
KEYWORDS = 'etcd governor patroni postgresql postgres ha zookeeper streaming replication'
|
||||
KEYWORDS = 'etcd governor patroni postgresql postgres ha haproxy confd zookeeper exhibitor consul streaming replication'
|
||||
|
||||
COVERAGE_XML = True
|
||||
COVERAGE_HTML = False
|
||||
@@ -51,12 +51,15 @@ CLASSIFIERS = [
|
||||
'Operating System :: POSIX :: Linux',
|
||||
'Programming Language :: Python',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
'Programming Language :: Python :: 3.3',
|
||||
'Programming Language :: Python :: 3.4',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: Implementation :: CPython',
|
||||
]
|
||||
|
||||
CONSOLE_SCRIPTS = ['patroni = patroni:main', 'patronictl = patroni.ctl:ctl']
|
||||
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):
|
||||
@@ -73,7 +76,7 @@ class PyTest(TestCommand):
|
||||
def finalize_options(self):
|
||||
TestCommand.finalize_options(self)
|
||||
if self.cov_xml or self.cov_html:
|
||||
self.cov = ['--cov', MAIN_PACKAGE, '--cov', MAIN_PACKAGE, '--cov-report', 'term-missing']
|
||||
self.cov = ['--cov', MAIN_PACKAGE, '--cov-report', 'term-missing']
|
||||
if self.cov_xml:
|
||||
self.cov.extend(['--cov-report', 'xml'])
|
||||
if self.cov_html:
|
||||
@@ -91,7 +94,12 @@ class PyTest(TestCommand):
|
||||
params['args'] += self.cov
|
||||
if self.junitxml:
|
||||
params['args'] += self.junitxml
|
||||
params['args'] += ['--doctest-modules', MAIN_PACKAGE, '-s', '-vv']
|
||||
params['args'] += ['--doctest-modules', MAIN_PACKAGE, '-vv']
|
||||
|
||||
import logging
|
||||
silence = logging.WARNING
|
||||
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=os.getenv('LOGLEVEL', silence))
|
||||
params['args'] += ['-s' if logging.getLogger().getEffectiveLevel() < silence else '--capture=fd']
|
||||
errno = pytest.main(**params)
|
||||
sys.exit(errno)
|
||||
|
||||
@@ -113,8 +121,7 @@ def setup_package():
|
||||
# Some helper variables
|
||||
version = os.getenv('GO_PIPELINE_LABEL', VERSION)
|
||||
|
||||
requirements = 'requirements-py2.txt' if sys.version_info[0] == 2 else 'requirements-py3.txt'
|
||||
install_reqs = get_install_requirements(requirements)
|
||||
install_reqs = get_install_requirements('requirements.txt')
|
||||
|
||||
command_options = {'test': {'test_suite': ('setup.py', 'tests')}}
|
||||
if JUNIT_XML:
|
||||
@@ -136,12 +143,12 @@ def setup_package():
|
||||
long_description=read('README.rst'),
|
||||
classifiers=CLASSIFIERS,
|
||||
test_suite='tests',
|
||||
packages=setuptools.find_packages(exclude=['tests', 'tests.*']),
|
||||
packages=find_packages(exclude=['tests', 'tests.*']),
|
||||
package_data={MAIN_PACKAGE: ["*.json"]},
|
||||
install_requires=install_reqs,
|
||||
setup_requires=['six', 'flake8'],
|
||||
setup_requires=['flake8'],
|
||||
cmdclass=cmdclass,
|
||||
tests_require=['pytest-cov', 'pytest'],
|
||||
tests_require=['mock>=2.0.0', 'pytest-cov', 'pytest'],
|
||||
command_options=command_options,
|
||||
entry_points={'console_scripts': CONSOLE_SCRIPTS},
|
||||
)
|
||||
|
||||
+246
-77
@@ -1,168 +1,337 @@
|
||||
import datetime
|
||||
import json
|
||||
import psycopg2
|
||||
import unittest
|
||||
|
||||
from mock import Mock, patch
|
||||
from patroni.api import RestApiHandler, RestApiServer
|
||||
from patroni.dcs import Member
|
||||
from patroni.dcs import ClusterConfig, Member
|
||||
from patroni.utils import tzutc
|
||||
from six import BytesIO as IO
|
||||
from six.moves import BaseHTTPServer
|
||||
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler
|
||||
import socket
|
||||
from test_postgresql import psycopg2_connect, MockCursor
|
||||
|
||||
|
||||
class MockPostgresql(Mock):
|
||||
future_restart_time = datetime.datetime.now(tzutc) + datetime.timedelta(days=5)
|
||||
postmaster_start_time = datetime.datetime.now(tzutc)
|
||||
|
||||
|
||||
class MockPostgresql(object):
|
||||
|
||||
name = 'test'
|
||||
state = 'running'
|
||||
role = 'master'
|
||||
server_version = '999999'
|
||||
sysid = 'dummysysid'
|
||||
scope = 'dummy'
|
||||
pending_restart = True
|
||||
|
||||
def connection(self):
|
||||
@staticmethod
|
||||
def connection():
|
||||
return psycopg2_connect()
|
||||
|
||||
def is_running(self):
|
||||
return True
|
||||
@staticmethod
|
||||
def postmaster_start_time():
|
||||
return str(postmaster_start_time)
|
||||
|
||||
|
||||
class MockHa(Mock):
|
||||
class MockHa(object):
|
||||
|
||||
dcs = Mock()
|
||||
state_handler = MockPostgresql()
|
||||
|
||||
def schedule_restart(self):
|
||||
return 'restart'
|
||||
|
||||
def schedule_reinitialize(self):
|
||||
@staticmethod
|
||||
def reinitialize():
|
||||
return 'reinitialize'
|
||||
|
||||
def restart(self):
|
||||
@staticmethod
|
||||
def restart():
|
||||
return (True, '')
|
||||
|
||||
def restart_scheduled(self):
|
||||
@staticmethod
|
||||
def restart_scheduled():
|
||||
return False
|
||||
|
||||
def fetch_nodes_statuses(self, members):
|
||||
@staticmethod
|
||||
def delete_future_restart():
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def fetch_nodes_statuses(members):
|
||||
return [[None, True, None, None, {}]]
|
||||
|
||||
@staticmethod
|
||||
def schedule_future_restart(data):
|
||||
return True
|
||||
|
||||
class MockPatroni:
|
||||
@staticmethod
|
||||
def is_lagging(xlog):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_effective_tags():
|
||||
return {'nosync': True}
|
||||
|
||||
@staticmethod
|
||||
def wakeup():
|
||||
pass
|
||||
|
||||
|
||||
class MockPatroni(object):
|
||||
|
||||
postgresql = MockPostgresql()
|
||||
ha = MockHa()
|
||||
config = Mock()
|
||||
postgresql = ha.state_handler
|
||||
dcs = Mock()
|
||||
tags = {}
|
||||
version = '0.00'
|
||||
noloadbalance = Mock(return_value=False)
|
||||
scheduled_restart = {'schedule': future_restart_time,
|
||||
'postmaster_start_time': postgresql.postmaster_start_time()}
|
||||
|
||||
@staticmethod
|
||||
def sighup_handler():
|
||||
pass
|
||||
|
||||
|
||||
class MockRequest:
|
||||
class MockRequest(object):
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
def __init__(self, request):
|
||||
self.request = request.encode('utf-8')
|
||||
|
||||
def makefile(self, *args, **kwargs):
|
||||
return IO(self.path)
|
||||
return IO(self.request)
|
||||
|
||||
|
||||
class MockRestApiServer(RestApiServer):
|
||||
|
||||
def __init__(self, Handler, path):
|
||||
def __init__(self, Handler, request):
|
||||
self.socket = 0
|
||||
self.serve_forever = Mock()
|
||||
BaseHTTPServer.HTTPServer.__init__ = Mock()
|
||||
MockRestApiServer._BaseServer__is_shut_down = Mock()
|
||||
MockRestApiServer._BaseServer__shutdown_request = True
|
||||
config = {'listen': '127.0.0.1:8008', 'auth': 'test:test', 'certfile': 'dumb'}
|
||||
config = {'listen': '127.0.0.1:8008', 'auth': 'test:test'}
|
||||
super(MockRestApiServer, self).__init__(MockPatroni(), config)
|
||||
Handler(MockRequest(path), ('0.0.0.0', 8080), self)
|
||||
config['certfile'] = 'dumb'
|
||||
self.reload_config(config)
|
||||
Handler(MockRequest(request), ('0.0.0.0', 8080), self)
|
||||
|
||||
|
||||
@patch('ssl.wrap_socket', Mock(return_value=0))
|
||||
class TestRestApiHandler(unittest.TestCase):
|
||||
|
||||
_authorization = '\nAuthorization: Basic dGVzdDp0ZXN0'
|
||||
|
||||
def test_do_GET(self):
|
||||
MockRestApiServer(RestApiHandler, b'GET /replica')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica')
|
||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={})):
|
||||
MockRestApiServer(RestApiHandler, b'GET /replica')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica')
|
||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
|
||||
MockRestApiServer(RestApiHandler, b'GET /replica')
|
||||
MockRestApiServer(RestApiHandler, b'GET /master')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master')
|
||||
MockPatroni.dcs.cluster.leader.name = MockPostgresql.name
|
||||
MockRestApiServer(RestApiHandler, b'GET /replica')
|
||||
MockRestApiServer(RestApiHandler, 'GET /replica')
|
||||
MockPatroni.dcs.cluster = None
|
||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
|
||||
MockRestApiServer(RestApiHandler, b'GET /master')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master')
|
||||
with patch.object(MockHa, 'restart_scheduled', Mock(return_value=True)):
|
||||
MockRestApiServer(RestApiHandler, b'GET /master')
|
||||
MockRestApiServer(RestApiHandler, b'GET /master')
|
||||
MockRestApiServer(RestApiHandler, 'GET /master')
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, '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')
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'OPTIONS / HTTP/1.0'))
|
||||
|
||||
def test_do_GET_patroni(self):
|
||||
MockRestApiServer(RestApiHandler, b'GET /patroni')
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
|
||||
|
||||
def test_basicauth(self):
|
||||
MockRestApiServer(RestApiHandler, b'POST /restart HTTP/1.0')
|
||||
MockRestApiServer(RestApiHandler, b'POST /restart HTTP/1.0\nAuthorization:')
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'POST /restart HTTP/1.0'))
|
||||
MockRestApiServer(RestApiHandler, 'POST /restart HTTP/1.0\nAuthorization:')
|
||||
|
||||
def test_do_POST_restart(self):
|
||||
request = b'POST /restart HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0'
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_do_GET_config(self, mock_dcs):
|
||||
mock_dcs.cluster.config.data = {}
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /config'))
|
||||
mock_dcs.cluster.config = None
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /config'))
|
||||
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_do_PATCH_config(self, mock_dcs):
|
||||
config = {'postgresql': {'use_slots': False, 'use_pg_rewind': True, 'parameters': {'wal_level': 'logical'}}}
|
||||
mock_dcs.get_cluster.return_value.config = ClusterConfig.from_node(1, json.dumps(config))
|
||||
request = 'PATCH /config HTTP/1.0' + self._authorization
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request))
|
||||
request += '\nContent-Length: '
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request + '34\n\n{"postgresql":{"use_slots":false}}'))
|
||||
config['ttl'] = 5
|
||||
config['postgresql'].update({'use_slots': {'foo': True}, "parameters": None})
|
||||
config = json.dumps(config)
|
||||
request += str(len(config)) + '\n\n' + config
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
mock_dcs.set_config_value.return_value = False
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_do_PUT_config(self, mock_dcs):
|
||||
mock_dcs.get_cluster.return_value.config = ClusterConfig.from_node(1, '{}')
|
||||
request = 'PUT /config HTTP/1.0' + self._authorization + '\nContent-Length: '
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request + '2\n\n{}'))
|
||||
config = '{"foo": "bar"}'
|
||||
request += str(len(config)) + '\n\n' + config
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
mock_dcs.set_config_value.return_value = False
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
mock_dcs.get_cluster.return_value.config = ClusterConfig.from_node(1, config)
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
|
||||
@patch.object(MockPatroni, 'sighup_handler', Mock(side_effect=Exception))
|
||||
def test_do_POST_reload(self):
|
||||
with patch.object(MockPatroni, 'config') as mock_config:
|
||||
mock_config.reload_local_configuration.return_value = False
|
||||
MockRestApiServer(RestApiHandler, 'POST /reload HTTP/1.0' + self._authorization)
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'POST /reload HTTP/1.0' + self._authorization))
|
||||
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_do_POST_restart(self, mock_dcs):
|
||||
mock_dcs.get_cluster.return_value.is_paused.return_value = False
|
||||
request = 'POST /restart HTTP/1.0' + self._authorization
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request))
|
||||
|
||||
with patch.object(MockHa, 'restart', Mock(side_effect=Exception)):
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
|
||||
@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'
|
||||
post = request + '\nContent-Length: '
|
||||
|
||||
def make_request(request=None, **kwargs):
|
||||
request = json.dumps(kwargs) if request is None else request
|
||||
return '{0}{1}\n\n{2}'.format(post, len(request), request)
|
||||
|
||||
# empty request
|
||||
request = make_request('')
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
cluster.is_unlocked.return_value = False
|
||||
# invalid request
|
||||
request = make_request('foobar=baz')
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
with patch.object(MockHa, 'schedule_reinitialize', Mock(return_value=None)):
|
||||
# wrong role
|
||||
request = make_request(schedule=future_restart_time.isoformat(), role='unknown', postgres_version='9.5.3')
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
# wrong version
|
||||
request = make_request(schedule=future_restart_time.isoformat(), role='master', postgres_version='9.5.3.1')
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
# unknown filter
|
||||
request = make_request(schedule=future_restart_time.isoformat(), batman='lives')
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
# incorrect schedule
|
||||
request = make_request(schedule='2016-08-42 12:45TZ+1', role='master')
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
# everything fine, but the schedule is missing
|
||||
request = make_request(role='master', postgres_version='9.5.2')
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
for retval in (True, False):
|
||||
with patch.object(MockHa, 'schedule_future_restart', Mock(return_value=retval)):
|
||||
request = make_request(schedule=future_restart_time.isoformat())
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
with patch.object(MockHa, 'restart', Mock(return_value=(retval, "foo"))):
|
||||
request = make_request(role='master', postgres_version='9.5.2')
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
|
||||
mock_dcs.get_cluster.return_value.is_paused.return_value = True
|
||||
MockRestApiServer(RestApiHandler, make_request(schedule='2016-08-42 12:45TZ+1', role='master'))
|
||||
# Valid timeout
|
||||
MockRestApiServer(RestApiHandler, make_request(timeout='60s'))
|
||||
# Invalid timeout
|
||||
MockRestApiServer(RestApiHandler, make_request(timeout='42towels'))
|
||||
|
||||
def test_do_DELETE_restart(self):
|
||||
for retval in (True, False):
|
||||
with patch.object(MockHa, 'delete_future_restart', Mock(return_value=retval)):
|
||||
request = 'DELETE /restart HTTP/1.0' + self._authorization
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request))
|
||||
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_do_POST_reinitialize(self, mock_dcs):
|
||||
cluster = mock_dcs.get_cluster.return_value
|
||||
cluster.is_paused.return_value = False
|
||||
request = 'POST /reinitialize HTTP/1.0' + self._authorization
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
with patch.object(MockHa, 'reinitialize', Mock(return_value=None)):
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
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')
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
|
||||
with patch.object(MockPostgresql, 'connection', Mock(side_effect=psycopg2.OperationalError)):
|
||||
MockRestApiServer(RestApiHandler, b'GET /patroni')
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
@patch.object(MockHa, 'dcs')
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_do_POST_failover(self, dcs):
|
||||
dcs.loop_wait = 10
|
||||
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"}'
|
||||
|
||||
post = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: '
|
||||
|
||||
MockRestApiServer(RestApiHandler, post + '7\n\n{"1":2}')
|
||||
|
||||
request = post + '0\n\n'
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
|
||||
cluster.leader.name = 'postgresql1'
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
cluster.members = [Member(0, 'postgresql0', 30, {'api_url': 'http'})]
|
||||
|
||||
MockRestApiServer(RestApiHandler, post + '25\n\n{"leader": "postgresql1"}')
|
||||
|
||||
cluster.leader.name = 'postgresql2'
|
||||
request = post + '53\n\n{"leader": "postgresql1", "candidate": "postgresql2"}'
|
||||
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)
|
||||
|
||||
cluster.leader.name = 'postgresql1'
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
|
||||
cluster.members = [Member(0, 'postgresql0', 30, {'api_url': 'http'}),
|
||||
Member(0, 'postgresql2', 30, {'api_url': 'http'})]
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
|
||||
cluster.failover = None
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
|
||||
dcs.get_cluster.side_effect = [cluster]
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
|
||||
cluster2 = cluster.copy()
|
||||
cluster2.leader.name = 'postgresql0'
|
||||
dcs.get_cluster.side_effect = [cluster, cluster2]
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
|
||||
cluster2.leader.name = 'postgresql2'
|
||||
dcs.get_cluster.side_effect = [cluster, cluster2]
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
|
||||
dcs.get_cluster.side_effect = None
|
||||
dcs.manual_failover.return_value = False
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
dcs.manual_failover.return_value = True
|
||||
|
||||
with patch.object(MockHa, 'fetch_nodes_statuses', Mock(return_value=[])):
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
request = b'POST /failover HTTP/1.0\nAuthorization: Basic dGVzdDp0ZXN0\n' +\
|
||||
b'Content-Length: 50\n\n{"leader": "postgresql1", "member": "postgresql2"}'
|
||||
|
||||
# Valid future date
|
||||
request = post + '103\n\n{"leader": "postgresql1", "member": "postgresql2",' +\
|
||||
' "scheduled_at": "6016-02-15T18:13:30.568224+01:00"}'
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
with patch.object(MockPatroni, 'dcs') as d:
|
||||
d.manual_failover.return_value = False
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
|
||||
# Exception: No timezone specified
|
||||
request = post + '97\n\n{"leader": "postgresql1", "member": "postgresql2",' +\
|
||||
' "scheduled_at": "6016-02-15T18:13:30.568224"}'
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
|
||||
# Exception: Scheduled in the past
|
||||
request = post + '103\n\n{"leader": "postgresql1", "member": "postgresql2", "scheduled_at": "'
|
||||
MockRestApiServer(RestApiHandler, request + '1016-02-15T18:13:30.568224+01:00"}')
|
||||
|
||||
# Invalid date
|
||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, request + '2010-02-29T18:13:30.568224+01:00"}'))
|
||||
|
||||
@@ -8,7 +8,7 @@ from threading import Thread
|
||||
class TestAsyncExecutor(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.a = AsyncExecutor()
|
||||
self.a = AsyncExecutor(Mock())
|
||||
|
||||
@patch.object(Thread, 'start', Mock())
|
||||
def test_run_async(self):
|
||||
|
||||
+18
-18
@@ -1,12 +1,15 @@
|
||||
import unittest
|
||||
import requests
|
||||
import boto.ec2
|
||||
import requests
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from mock import Mock, patch
|
||||
from collections import namedtuple
|
||||
from patroni.scripts.aws import AWSConnection
|
||||
from patroni.scripts.aws import AWSConnection, main as _main
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
|
||||
class MockEc2Connection:
|
||||
class MockEc2Connection(object):
|
||||
|
||||
def __init__(self, error=False):
|
||||
self.error = error
|
||||
@@ -23,7 +26,7 @@ class MockEc2Connection:
|
||||
return True
|
||||
|
||||
|
||||
class MockResponse:
|
||||
class MockResponse(object):
|
||||
|
||||
def __init__(self, content):
|
||||
self.content = content
|
||||
@@ -35,15 +38,6 @@ class MockResponse:
|
||||
|
||||
class TestAWSConnection(unittest.TestCase):
|
||||
|
||||
def __init__(self, method_name='runTest'):
|
||||
super(TestAWSConnection, self).__init__(method_name)
|
||||
|
||||
def set_error(self):
|
||||
self.error = True
|
||||
|
||||
def set_json_error(self):
|
||||
self.json_error = True
|
||||
|
||||
def boto_ec2_connect_to_region(self, region):
|
||||
return MockEc2Connection(self.error)
|
||||
|
||||
@@ -74,21 +68,27 @@ class TestAWSConnection(unittest.TestCase):
|
||||
self.assertTrue(self.conn.on_role_change('master'))
|
||||
|
||||
def test_non_aws(self):
|
||||
self.set_error()
|
||||
self.error = True
|
||||
conn = AWSConnection('test')
|
||||
self.assertFalse(conn.aws_available())
|
||||
self.assertFalse(conn._tag_ebs('master'))
|
||||
self.assertFalse(conn._tag_ec2('master'))
|
||||
|
||||
def test_aws_bizare_response(self):
|
||||
self.set_json_error()
|
||||
self.json_error = True
|
||||
conn = AWSConnection('test')
|
||||
self.assertFalse(conn.aws_available())
|
||||
|
||||
def test_aws_tag_ebs_error(self):
|
||||
self.set_error()
|
||||
self.error = True
|
||||
self.assertFalse(self.conn._tag_ebs("master"))
|
||||
|
||||
def test_aws_tag_ec2_error(self):
|
||||
self.set_error()
|
||||
self.error = True
|
||||
self.assertFalse(self.conn._tag_ec2("master"))
|
||||
|
||||
@patch('sys.exit', Mock())
|
||||
def test_main(self):
|
||||
self.assertIsNone(_main())
|
||||
sys.argv = ['aws.py', 'on_start', 'replica', 'foo']
|
||||
self.assertIsNone(_main())
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import unittest
|
||||
|
||||
from mock import Mock, patch
|
||||
from patroni.callback_executor import CallbackExecutor
|
||||
|
||||
|
||||
class TestCallbackExecutor(unittest.TestCase):
|
||||
|
||||
@patch('subprocess.Popen')
|
||||
def test_callback_executor(self, mock_popen):
|
||||
mock_popen.return_value.wait.side_effect = Exception
|
||||
mock_popen.return_value.poll.return_value = None
|
||||
|
||||
ce = CallbackExecutor()
|
||||
self.assertIsNone(ce.call([]))
|
||||
ce.join()
|
||||
|
||||
self.assertIsNone(ce.call([]))
|
||||
|
||||
mock_popen.side_effect = Exception
|
||||
ce = CallbackExecutor()
|
||||
ce._callback_event.wait = Mock(side_effect=[None, Exception])
|
||||
self.assertIsNone(ce.call([]))
|
||||
ce.join()
|
||||
@@ -0,0 +1,81 @@
|
||||
import os
|
||||
import unittest
|
||||
import sys
|
||||
|
||||
from mock import MagicMock, Mock, patch
|
||||
from patroni.config import Config
|
||||
from six.moves import builtins
|
||||
|
||||
|
||||
class TestConfig(unittest.TestCase):
|
||||
|
||||
@patch('os.path.isfile', Mock(return_value=True))
|
||||
@patch('json.load', Mock(side_effect=Exception))
|
||||
@patch.object(builtins, 'open', MagicMock())
|
||||
def setUp(self):
|
||||
sys.argv = ['patroni.py']
|
||||
os.environ[Config.PATRONI_CONFIG_VARIABLE] = 'restapi: {}\npostgresql: {data_dir: foo}'
|
||||
self.config = Config()
|
||||
|
||||
def test_no_config(self):
|
||||
self.assertRaises(SystemExit, Config)
|
||||
|
||||
def test_set_dynamic_configuration(self):
|
||||
with patch.object(Config, '_build_effective_configuration', Mock(side_effect=Exception)):
|
||||
self.assertIsNone(self.config.set_dynamic_configuration({'foo': 'bar'}))
|
||||
self.assertTrue(self.config.set_dynamic_configuration({'synchronous_mode': True}))
|
||||
|
||||
def test_reload_local_configuration(self):
|
||||
os.environ.update({
|
||||
'PATRONI_NAME': 'postgres0',
|
||||
'PATRONI_NAMESPACE': '/patroni/',
|
||||
'PATRONI_SCOPE': 'batman2',
|
||||
'PATRONI_RESTAPI_USERNAME': 'username',
|
||||
'PATRONI_RESTAPI_PASSWORD': 'password',
|
||||
'PATRONI_RESTAPI_LISTEN': '0.0.0.0:8008',
|
||||
'PATRONI_RESTAPI_CONNECT_ADDRESS': '127.0.0.1:8008',
|
||||
'PATRONI_RESTAPI_CERTFILE': '/certfile',
|
||||
'PATRONI_RESTAPI_KEYFILE': '/keyfile',
|
||||
'PATRONI_POSTGRESQL_LISTEN': '0.0.0.0:5432',
|
||||
'PATRONI_POSTGRESQL_CONNECT_ADDRESS': '127.0.0.1:5432',
|
||||
'PATRONI_POSTGRESQL_DATA_DIR': 'data/postgres0',
|
||||
'PATRONI_POSTGRESQL_PGPASS': '/tmp/pgpass0',
|
||||
'PATRONI_ETCD_HOST': '127.0.0.1:2379',
|
||||
'PATRONI_ETCD_URL': 'https://127.0.0.1:2379',
|
||||
'PATRONI_ETCD_PROXY': 'http://127.0.0.1:2379',
|
||||
'PATRONI_ETCD_SRV': 'test',
|
||||
'PATRONI_ETCD_CACERT': '/cacert',
|
||||
'PATRONI_ETCD_CERT': '/cert',
|
||||
'PATRONI_ETCD_KEY': '/key',
|
||||
'PATRONI_CONSUL_HOST': '127.0.0.1:8500',
|
||||
'PATRONI_ZOOKEEPER_HOSTS': "'host1:2181','host2:2181'",
|
||||
'PATRONI_EXHIBITOR_HOSTS': 'host1,host2',
|
||||
'PATRONI_EXHIBITOR_PORT': '8181',
|
||||
'PATRONI_foo_HOSTS': '[host1,host2', # Exception in parse_list
|
||||
'PATRONI_SUPERUSER_USERNAME': 'postgres',
|
||||
'PATRONI_SUPERUSER_PASSWORD': 'zalando',
|
||||
'PATRONI_REPLICATION_USERNAME': 'replicator',
|
||||
'PATRONI_REPLICATION_PASSWORD': 'rep-pass',
|
||||
'PATRONI_admin_PASSWORD': 'admin',
|
||||
'PATRONI_admin_OPTIONS': 'createrole,createdb'
|
||||
})
|
||||
sys.argv = ['patroni.py', 'postgres0.yml']
|
||||
config = Config()
|
||||
with patch.object(Config, '_load_config_file', Mock(return_value={'restapi': {}})):
|
||||
with patch.object(Config, '_build_effective_configuration', Mock(side_effect=Exception)):
|
||||
self.assertRaises(Exception, config.reload_local_configuration, True)
|
||||
self.assertTrue(config.reload_local_configuration(True))
|
||||
self.assertTrue(config.reload_local_configuration())
|
||||
|
||||
@patch('tempfile.mkstemp', Mock(return_value=[3000, 'blabla']))
|
||||
@patch('os.path.exists', Mock(return_value=True))
|
||||
@patch('os.remove', Mock(side_effect=IOError))
|
||||
@patch('os.close', Mock(side_effect=IOError))
|
||||
@patch('os.rename', Mock(return_value=None))
|
||||
@patch('json.dump', Mock())
|
||||
def test_save_cache(self):
|
||||
self.config.set_dynamic_configuration({'ttl': 30, 'postgresql': {'foo': 'bar'}})
|
||||
with patch('os.fdopen', Mock(side_effect=IOError)):
|
||||
self.config.save_cache()
|
||||
with patch('os.fdopen', MagicMock()):
|
||||
self.config.save_cache()
|
||||
@@ -0,0 +1,164 @@
|
||||
import consul
|
||||
import unittest
|
||||
|
||||
from consul import ConsulException, NotFound
|
||||
from mock import Mock, patch
|
||||
from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulInternalError, ConsulError, HTTPClient
|
||||
from test_etcd import SleepException
|
||||
|
||||
|
||||
def kv_get(self, key, **kwargs):
|
||||
if key == 'service/test/members/postgresql1':
|
||||
return '1', {'Session': 'fd4f44fe-2cac-bba5-a60b-304b51ff39b7'}
|
||||
if key == 'service/test/':
|
||||
return None, None
|
||||
if key == 'service/good/leader':
|
||||
return '1', None
|
||||
if key == 'service/good/':
|
||||
return ('6429',
|
||||
[{'CreateIndex': 1334, 'Flags': 0, 'Key': key + 'failover', 'LockIndex': 0,
|
||||
'ModifyIndex': 1334, 'Value': b''},
|
||||
{'CreateIndex': 1334, 'Flags': 0, 'Key': key + 'initialize', 'LockIndex': 0,
|
||||
'ModifyIndex': 1334, 'Value': b'postgresql0'},
|
||||
{'CreateIndex': 2621, 'Flags': 0, 'Key': key + 'leader', 'LockIndex': 1,
|
||||
'ModifyIndex': 2621, 'Session': 'fd4f44fe-2cac-bba5-a60b-304b51ff39b7', 'Value': b'postgresql1'},
|
||||
{'CreateIndex': 6156, 'Flags': 0, 'Key': key + 'members/postgresql0', 'LockIndex': 1,
|
||||
'ModifyIndex': 6156, 'Session': '782e6da4-ed02-3aef-7963-99a90ed94b53',
|
||||
'Value': ('postgres://replicator:[email protected]:5432/postgres' +
|
||||
'?application_name=http://127.0.0.1:8008/patroni').encode('utf-8')},
|
||||
{'CreateIndex': 2630, 'Flags': 0, 'Key': key + 'members/postgresql1', 'LockIndex': 1,
|
||||
'ModifyIndex': 2630, 'Session': 'fd4f44fe-2cac-bba5-a60b-304b51ff39b7',
|
||||
'Value': ('postgres://replicator:[email protected]:5433/postgres' +
|
||||
'?application_name=http://127.0.0.1:8009/patroni').encode('utf-8')},
|
||||
{'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'optime/leader', 'LockIndex': 0,
|
||||
'ModifyIndex': 6429, 'Value': b'4496294792'},
|
||||
{'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'sync', 'LockIndex': 0,
|
||||
'ModifyIndex': 6429, 'Value': b'{"leader": "leader", "sync_standby": null}'}])
|
||||
raise ConsulException
|
||||
|
||||
|
||||
class TestHTTPClient(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = HTTPClient('127.0.0.1', '8500', 'http', False)
|
||||
self.client.http.request = Mock()
|
||||
|
||||
def test_get(self):
|
||||
self.client.get(Mock(), '')
|
||||
self.client.get(Mock(), '', {'wait': '1s', 'index': 1})
|
||||
self.client.http.request.return_value.status = 500
|
||||
self.assertRaises(ConsulInternalError, self.client.get, Mock(), '')
|
||||
|
||||
def test_unknown_method(self):
|
||||
try:
|
||||
self.client.bla(Mock(), '')
|
||||
self.assertFail()
|
||||
except Exception as e:
|
||||
self.assertTrue(isinstance(e, AttributeError))
|
||||
|
||||
def test_put(self):
|
||||
self.client.put(Mock(), '/v1/session/create')
|
||||
self.client.put(Mock(), '/v1/session/create', data='{"foo": "bar"}')
|
||||
|
||||
|
||||
@patch.object(consul.Consul.KV, 'get', kv_get)
|
||||
class TestConsul(unittest.TestCase):
|
||||
|
||||
@patch.object(consul.Consul.Session, 'create', Mock(return_value='fd4f44fe-2cac-bba5-a60b-304b51ff39b7'))
|
||||
@patch.object(consul.Consul.Session, 'renew', Mock(side_effect=NotFound))
|
||||
@patch.object(consul.Consul.KV, 'get', kv_get)
|
||||
@patch.object(consul.Consul.KV, 'delete', Mock())
|
||||
def setUp(self):
|
||||
self.c = Consul({'ttl': 30, 'scope': 'test', 'name': 'postgresql1', 'host': 'localhost:1', 'retry_timeout': 10})
|
||||
self.c._base_path = '/service/good'
|
||||
self.c._load_cluster()
|
||||
|
||||
@patch('time.sleep', Mock(side_effect=SleepException))
|
||||
@patch.object(consul.Consul.Session, 'create', Mock(side_effect=ConsulException))
|
||||
def test_create_session(self):
|
||||
self.c._session = None
|
||||
self.assertRaises(SleepException, self.c.create_session)
|
||||
|
||||
@patch.object(consul.Consul.Session, 'renew', Mock(side_effect=NotFound))
|
||||
@patch.object(consul.Consul.Session, 'create', Mock(side_effect=ConsulException))
|
||||
def test_referesh_session(self):
|
||||
self.c._session = '1'
|
||||
self.assertFalse(self.c.refresh_session())
|
||||
self.c._last_session_refresh = 0
|
||||
self.assertRaises(ConsulError, self.c.refresh_session)
|
||||
|
||||
@patch.object(consul.Consul.KV, 'delete', Mock())
|
||||
def test_get_cluster(self):
|
||||
self.c._base_path = '/service/test'
|
||||
self.assertIsInstance(self.c.get_cluster(), Cluster)
|
||||
self.assertIsInstance(self.c.get_cluster(), Cluster)
|
||||
self.c._base_path = '/service/fail'
|
||||
self.assertRaises(ConsulError, self.c.get_cluster)
|
||||
self.c._base_path = '/service/good'
|
||||
self.c._session = 'fd4f44fe-2cac-bba5-a60b-304b51ff39b8'
|
||||
self.assertIsInstance(self.c.get_cluster(), Cluster)
|
||||
|
||||
@patch.object(consul.Consul.KV, 'delete', Mock(side_effect=[ConsulException, True, True]))
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=[True, ConsulException]))
|
||||
def test_touch_member(self):
|
||||
self.c.refresh_session = Mock(return_value=True)
|
||||
self.c.touch_member('balbla')
|
||||
self.c.touch_member('balbla')
|
||||
self.c.touch_member('balbla')
|
||||
self.c.refresh_session = Mock(return_value=False)
|
||||
self.c.touch_member('balbla')
|
||||
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(return_value=False))
|
||||
def test_take_leader(self):
|
||||
self.c.set_ttl(20)
|
||||
self.c.refresh_session = Mock()
|
||||
self.c.take_leader()
|
||||
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
|
||||
def test_set_failover_value(self):
|
||||
self.c.set_failover_value('')
|
||||
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
|
||||
def test_set_config_value(self):
|
||||
self.c.set_config_value('')
|
||||
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException))
|
||||
def test_write_leader_optime(self):
|
||||
self.c.write_leader_optime('1')
|
||||
|
||||
@patch.object(consul.Consul.Session, 'renew', Mock())
|
||||
def test_update_leader(self):
|
||||
self.c.update_leader()
|
||||
|
||||
@patch.object(consul.Consul.KV, 'delete', Mock(return_value=True))
|
||||
def test_delete_leader(self):
|
||||
self.c.delete_leader()
|
||||
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
|
||||
def test_initialize(self):
|
||||
self.c.initialize()
|
||||
|
||||
@patch.object(consul.Consul.KV, 'delete', Mock(return_value=True))
|
||||
def test_cancel_initialization(self):
|
||||
self.c.cancel_initialization()
|
||||
|
||||
@patch.object(consul.Consul.KV, 'delete', Mock(return_value=True))
|
||||
def test_delete_cluster(self):
|
||||
self.c.delete_cluster()
|
||||
|
||||
@patch.object(AbstractDCS, 'watch', Mock())
|
||||
def test_watch(self):
|
||||
self.c.watch(None, 1)
|
||||
self.c._name = ''
|
||||
self.c.watch(6429, 1)
|
||||
with patch.object(consul.Consul.KV, 'get', Mock(side_effect=ConsulException)):
|
||||
self.c.watch(6429, 1)
|
||||
|
||||
def test_set_retry_timeout(self):
|
||||
self.c.set_retry_timeout(10)
|
||||
|
||||
@patch.object(consul.Consul.KV, 'delete', Mock(return_value=True))
|
||||
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
|
||||
def test_sync_state(self):
|
||||
self.assertTrue(self.c.set_sync_state_value('{}'))
|
||||
self.assertTrue(self.c.delete_sync_state())
|
||||
+343
-279
@@ -1,376 +1,440 @@
|
||||
#!/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
|
||||
import os
|
||||
import requests
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
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 mock import patch, Mock
|
||||
from patroni.ctl import ctl, members, store_config, load_config, output_members, request_patroni, get_dcs, parse_dcs, \
|
||||
get_all_members, get_any_member, get_cursor, query_member, configure, PatroniCtlException
|
||||
from patroni.dcs.etcd import Client
|
||||
from psycopg2 import OperationalError
|
||||
from test_etcd import etcd_read, requests_get, socket_getaddrinfo, MockResponse
|
||||
from test_ha import get_cluster_initialized_without_leader, get_cluster_initialized_with_leader, \
|
||||
get_cluster_initialized_with_only_leader, MockPostgresql, MockPatroni, run_async, \
|
||||
get_cluster_not_initialized_without_leader
|
||||
from test_etcd import etcd_read, etcd_write, requests_get, MockResponse
|
||||
get_cluster_initialized_with_only_leader, get_cluster_not_initialized_without_leader
|
||||
from test_postgresql import MockConnect, psycopg2_connect
|
||||
|
||||
CONFIG_FILE_PATH = './test-ctl.yaml'
|
||||
|
||||
|
||||
def test_rw_config():
|
||||
runner = CliRunner()
|
||||
config = {'a':'b'}
|
||||
with runner.isolated_filesystem():
|
||||
store_config(config, CONFIG_FILE_PATH + '/dummy')
|
||||
store_config({'etcd': {'host': 'localhost:2379'}}, CONFIG_FILE_PATH + '/dummy')
|
||||
sys.argv = ['patronictl.py', '']
|
||||
load_config(CONFIG_FILE_PATH + '/dummy', None)
|
||||
load_config(CONFIG_FILE_PATH + '/dummy', '0.0.0.0')
|
||||
os.remove(CONFIG_FILE_PATH + '/dummy')
|
||||
os.rmdir(CONFIG_FILE_PATH)
|
||||
|
||||
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}}))
|
||||
@patch('patroni.ctl.load_config',
|
||||
Mock(return_value={'postgresql': {'data_dir': '.', 'parameters': {}, 'retry_timeout': 5},
|
||||
'restapi': {'auth': 'u:p', 'listen': ''}, 'etcd': {'host': 'localhost:2379'}}))
|
||||
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('socket.getaddrinfo', socket_getaddrinfo)
|
||||
def setUp(self):
|
||||
with patch.object(Client, 'machines') as mock_machines:
|
||||
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
|
||||
self.runner = CliRunner()
|
||||
self.e = get_dcs({'etcd': {'ttl': 30, 'host': 'ok:2379', 'retry_timeout': 10}}, 'foo')
|
||||
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
def test_get_cursor(self):
|
||||
c = get_cursor(get_cluster_initialized_without_leader(), role='master')
|
||||
assert c is None
|
||||
self.assertIsNone(get_cursor(get_cluster_initialized_without_leader(), {}, role='master'))
|
||||
|
||||
c = get_cursor(get_cluster_initialized_with_leader(), role='master')
|
||||
assert c is not None
|
||||
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), {}, role='master'))
|
||||
|
||||
c = get_cursor(get_cluster_initialized_with_leader(), role='replica')
|
||||
# # MockCursor returns pg_is_in_recovery as false
|
||||
assert c is None
|
||||
# MockCursor returns pg_is_in_recovery as false
|
||||
self.assertIsNone(get_cursor(get_cluster_initialized_with_leader(), {}, role='replica'))
|
||||
|
||||
c = get_cursor(get_cluster_initialized_with_leader(), role='any')
|
||||
assert c is not None
|
||||
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), {'database': 'foo'}, role='any'))
|
||||
|
||||
def test_parse_dcs(self):
|
||||
assert parse_dcs(None) is None
|
||||
assert parse_dcs('localhost') == {'etcd': {'host': 'localhost:2379'}}
|
||||
assert parse_dcs('') == {'etcd': {'host': 'localhost:2379'}}
|
||||
assert parse_dcs('localhost:8500') == {'consul': {'host': 'localhost:8500'}}
|
||||
assert parse_dcs('zookeeper://localhost') == {'zookeeper': {'hosts': ['localhost:2181']}}
|
||||
assert parse_dcs('exhibitor://dummy') == {'exhibitor': {'hosts': ['dummy'], 'port': 8181}}
|
||||
assert parse_dcs('consul://localhost') == {'consul': {'host': 'localhost:8500'}}
|
||||
self.assertRaises(PatroniCtlException, parse_dcs, 'invalid://test')
|
||||
|
||||
def test_output_members(self):
|
||||
cluster = get_cluster_initialized_with_leader()
|
||||
output_members(cluster, name='abc', format='pretty')
|
||||
output_members(cluster, name='abc', format='json')
|
||||
output_members(cluster, name='abc', format='tsv')
|
||||
self.assertIsNone(output_members(cluster, name='abc', fmt='pretty'))
|
||||
self.assertIsNone(output_members(cluster, name='abc', fmt='json'))
|
||||
self.assertIsNone(output_members(cluster, name='abc', fmt='tsv'))
|
||||
|
||||
@patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
|
||||
@patch('patroni.etcd.Etcd.get_etcd_client', Mock(return_value=None))
|
||||
@patch('patroni.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()
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
@patch('patroni.ctl.request_patroni', Mock(return_value=MockResponse()))
|
||||
def test_failover(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value = self.e
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
mock_get_dcs.return_value.set_failover_value = Mock()
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n\ny')
|
||||
assert 'leader' in result.output
|
||||
|
||||
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 = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n2300-01-01T12:23:00\ny')
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
|
||||
other
|
||||
N''')
|
||||
assert 'Aborting failover' in str(result.exception)
|
||||
with patch('patroni.dcs.Cluster.is_paused', Mock(return_value=True)):
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy', '--force', '--scheduled', '2015-01-01T12:00:00'])
|
||||
assert result.exit_code == 1
|
||||
|
||||
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)
|
||||
# Aborting failover,as we anser NO to the confirmation
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n\nN')
|
||||
assert result.exit_code == 1
|
||||
|
||||
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader
|
||||
Reality
|
||||
y''')
|
||||
assert 'Reality does not exist' in str(result.exception)
|
||||
# Target and source are equal
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nleader\n\ny')
|
||||
assert result.exit_code == 1
|
||||
|
||||
result = runner.invoke(ctl, ['failover', 'dummy', '--force'])
|
||||
assert 'Failing over to new leader' in result.output
|
||||
# Reality is not part of this cluster
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nReality\n\ny')
|
||||
assert result.exit_code == 1
|
||||
|
||||
result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='dummy')
|
||||
assert 'is not the leader of cluster' in str(result.exception)
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy', '--force'])
|
||||
assert 'Member' in result.output
|
||||
|
||||
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)
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy', '--force', '--scheduled', '2015-01-01T12:00:00+01:00'])
|
||||
assert result.exit_code == 0
|
||||
|
||||
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)
|
||||
# Invalid timestamp
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy', '--force', '--scheduled', 'invalid'])
|
||||
assert result.exit_code != 0
|
||||
|
||||
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''')
|
||||
# Invalid timestamp
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy', '--force', '--scheduled', '2115-02-30T12:00:00+01:00'])
|
||||
assert result.exit_code != 0
|
||||
|
||||
# Specifying wrong leader
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='dummy')
|
||||
assert result.exit_code == 1
|
||||
|
||||
with patch('patroni.ctl.request_patroni', Mock(side_effect=Exception)):
|
||||
# Non-responding patroni
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n2300-01-01T12:23:00\ny')
|
||||
assert 'falling back to DCS' in result.output
|
||||
|
||||
with patch('patroni.ctl.request_patroni') as mocked:
|
||||
mocked.return_value.status_code = 500
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n\ny')
|
||||
assert 'Failover failed' in result.output
|
||||
|
||||
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
|
||||
# No members available
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_only_leader
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n\ny')
|
||||
assert result.exit_code == 1
|
||||
|
||||
# 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)
|
||||
# No master available
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_without_leader
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='leader\nother\n\ny')
|
||||
assert result.exit_code == 1
|
||||
|
||||
# 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')
|
||||
def test_get_dcs(self):
|
||||
self.assertRaises(PatroniCtlException, get_dcs, {'dummy': {}}, 'dummy')
|
||||
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
@patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None)))
|
||||
def test_query(self):
|
||||
runner = CliRunner()
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
@patch.object(etcd.Client, 'read', etcd_read)
|
||||
def test_query(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value = self.e
|
||||
# Mutually exclusive
|
||||
result = self.runner.invoke(ctl, ['query', 'alpha', '--member', 'abc', '--role', 'master'])
|
||||
assert result.exit_code == 1
|
||||
|
||||
with 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')
|
||||
with self.runner.isolated_filesystem():
|
||||
with open('dummy', 'w') as dummy_file:
|
||||
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)
|
||||
# Mutually exclusive
|
||||
result = self.runner.invoke(ctl, ['query', 'alpha', '--file', 'dummy', '--command', 'dummy'])
|
||||
assert result.exit_code == 1
|
||||
|
||||
result = runner.invoke(ctl, ['query', 'alpha', '--file', 'dummy'])
|
||||
result = self.runner.invoke(ctl, ['query', 'alpha', '--file', 'dummy'])
|
||||
assert result.exit_code == 0
|
||||
|
||||
os.remove('dummy')
|
||||
os.remove('dummy')
|
||||
|
||||
result = runner.invoke(ctl, ['query', 'alpha', '--command', 'SELECT 1'])
|
||||
assert 'mock column' in result.output
|
||||
result = self.runner.invoke(ctl, ['query', 'alpha', '--command', 'SELECT 1'])
|
||||
assert 'mock column' in result.output
|
||||
|
||||
# --command or --file is mandatory
|
||||
result = self.runner.invoke(ctl, ['query', 'alpha'])
|
||||
assert result.exit_code == 1
|
||||
|
||||
result = self.runner.invoke(ctl, ['query', 'alpha', '--command', 'SELECT 1', '--username', 'root',
|
||||
'--password', '--dbname', 'postgres'], input='ab\nab')
|
||||
assert 'mock column' in result.output
|
||||
|
||||
@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)
|
||||
with patch('patroni.ctl.get_cursor', Mock(return_value=MockConnect().cursor())):
|
||||
rows = query_member(None, None, None, 'master', 'SELECT pg_is_in_recovery()', {})
|
||||
self.assertTrue('False' in str(rows))
|
||||
|
||||
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()')
|
||||
assert rows == (None, None)
|
||||
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()', {})
|
||||
self.assertEquals(rows, (None, None))
|
||||
|
||||
with patch('test_postgresql.MockCursor.execute', Mock(side_effect=OperationalError('bla'))):
|
||||
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()', {})
|
||||
|
||||
with patch('patroni.ctl.get_cursor', Mock(return_value=None)):
|
||||
rows = query_member(None, None, None, None, 'SELECT pg_is_in_recovery()')
|
||||
assert 'No connection to' in str(rows)
|
||||
rows = query_member(None, None, None, None, 'SELECT pg_is_in_recovery()', {})
|
||||
self.assertTrue('No connection to' in str(rows))
|
||||
|
||||
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()')
|
||||
assert 'No connection to' in str(rows)
|
||||
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()', {})
|
||||
self.assertTrue('No connection to' in str(rows))
|
||||
|
||||
with patch('patroni.ctl.get_cursor', Mock(side_effect=psycopg2.OperationalError('bla'))):
|
||||
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()')
|
||||
with patch('patroni.ctl.get_cursor', Mock(side_effect=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.ctl.get_dcs')
|
||||
def test_dsn(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
result = self.runner.invoke(ctl, ['dsn', 'alpha'])
|
||||
assert 'host=127.0.0.1 port=5435' in result.output
|
||||
|
||||
@patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
|
||||
def test_dsn(self):
|
||||
runner = CliRunner()
|
||||
# Mutually exclusive options
|
||||
result = self.runner.invoke(ctl, ['dsn', 'alpha', '--role', 'master', '--member', 'dummy'])
|
||||
assert result.exit_code == 1
|
||||
|
||||
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
|
||||
# Non-existing member
|
||||
result = self.runner.invoke(ctl, ['dsn', 'alpha', '--member', 'dummy'])
|
||||
assert result.exit_code == 1
|
||||
|
||||
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()
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
def test_restart_reinit(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha'], input='y\n\nnow')
|
||||
assert 'Failed: restart for' in result.output
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = 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 = self.runner.invoke(ctl, ['reinit', 'alpha'], input='y')
|
||||
assert result.exit_code == 1
|
||||
|
||||
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)
|
||||
# successful reinit
|
||||
result = self.runner.invoke(ctl, ['reinit', 'alpha', 'other'], input='y')
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Aborted restart
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha'], input='N')
|
||||
assert result.exit_code == 1
|
||||
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', '--pending', '--force'])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Not a member
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', 'dummy', '--any'], input='y')
|
||||
assert result.exit_code == 1
|
||||
|
||||
# Wrong pg version
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', '--any', '--pg-version', '9.1'], input='y')
|
||||
assert 'Error: PostgreSQL version' in result.output
|
||||
assert result.exit_code == 1
|
||||
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', '--pending', '--force', '--timeout', '10min'])
|
||||
assert result.exit_code == 0
|
||||
|
||||
with patch('requests.delete', Mock(return_value=MockResponse(500))):
|
||||
# normal restart, the schedule is actually parsed, but not validated in patronictl
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', 'other', '--force',
|
||||
'--scheduled', '2300-10-01T14:30'])
|
||||
assert 'Failed: flush scheduled restart' in result.output
|
||||
|
||||
with patch('patroni.dcs.Cluster.is_paused', Mock(return_value=True)):
|
||||
result = self.runner.invoke(ctl,
|
||||
['restart', 'alpha', 'other', '--force', '--scheduled', '2300-10-01T14:30'])
|
||||
assert result.exit_code == 1
|
||||
|
||||
with patch('requests.post', Mock(return_value=MockResponse())):
|
||||
result = runner.invoke(ctl, ['restart', 'alpha', '--dcs', '8.8.8.8'], input='y')
|
||||
# normal restart, the schedule is actually parsed, but not validated in patronictl
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', '--pg-version', '42.0.0',
|
||||
'--scheduled', '2300-10-01T14:30'], input='y')
|
||||
assert result.exit_code == 0
|
||||
|
||||
@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()
|
||||
with patch('requests.post', Mock(return_value=MockResponse(204))):
|
||||
# get restart with the non-200 return code
|
||||
# normal restart, the schedule is actually parsed, but not validated in patronictl
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', '--pg-version', '42.0.0',
|
||||
'--scheduled', '2300-10-01T14:30'], input='y')
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'], input='alpha\nslave')
|
||||
# force restart with restart already present
|
||||
with patch('patroni.ctl.request_patroni', Mock(return_value=MockResponse(204))):
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', 'other', '--force',
|
||||
'--scheduled', '2300-10-01T14:30'])
|
||||
assert result.exit_code == 0
|
||||
|
||||
with patch('requests.post', Mock(return_value=MockResponse(202))):
|
||||
# get restart with the non-200 return code
|
||||
# normal restart, the schedule is actually parsed, but not validated in patronictl
|
||||
result = self.runner.invoke(
|
||||
ctl, ['restart', 'alpha', '--pg-version', '99.0.0', '--scheduled', '2300-10-01T14:30'], input='y'
|
||||
)
|
||||
assert 'Success: restart scheduled' in result.output
|
||||
assert result.exit_code == 0
|
||||
|
||||
with patch('requests.post', Mock(return_value=MockResponse(409))):
|
||||
# get restart with the non-200 return code
|
||||
# normal restart, the schedule is actually parsed, but not validated in patronictl
|
||||
result = self.runner.invoke(
|
||||
ctl, ['restart', 'alpha', '--pg-version', '99.0.0', '--scheduled', '2300-10-01T14:30'], input='y'
|
||||
)
|
||||
assert 'Failed: another restart is already' in result.output
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
def test_remove(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
result = self.runner.invoke(ctl, ['remove', 'alpha'], input='alpha\nslave')
|
||||
assert 'Please confirm' in result.output
|
||||
assert 'You are about to remove all' in result.output
|
||||
assert 'You did not exactly type' in str(result.exception)
|
||||
# Not typing an exact confirmation
|
||||
assert result.exit_code == 1
|
||||
|
||||
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)
|
||||
# master specified does not match master of cluster
|
||||
result = self.runner.invoke(ctl, ['remove', 'alpha'], input='alpha\nYes I am aware\nslave')
|
||||
assert result.exit_code == 1
|
||||
|
||||
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)
|
||||
# cluster specified on cmdline does not match verification prompt
|
||||
result = self.runner.invoke(ctl, ['remove', 'alpha'], input='beta\nleader')
|
||||
assert result.exit_code == 1
|
||||
|
||||
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)
|
||||
result = self.runner.invoke(ctl, ['remove', 'alpha'], input='alpha\nYes I am aware\nleader')
|
||||
assert result.exit_code == 0
|
||||
|
||||
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):
|
||||
@patch('requests.post', Mock(side_effect=requests.exceptions.ConnectionError('foo')))
|
||||
def test_request_patroni(self):
|
||||
member = get_cluster_initialized_with_leader().leader.member
|
||||
self.assertRaises(requests.exceptions.ConnectionError, post_patroni, member, 'dummy', {})
|
||||
self.assertRaises(requests.exceptions.ConnectionError, request_patroni, member, 'post', 'dummy', {})
|
||||
|
||||
def test_ctl(self):
|
||||
runner = CliRunner()
|
||||
self.runner.invoke(ctl, ['list'])
|
||||
|
||||
runner.invoke(ctl, ['list'])
|
||||
|
||||
result = runner.invoke(ctl, ['--help'])
|
||||
result = self.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
|
||||
self.assertIsNone(get_any_member(get_cluster_initialized_without_leader(), role='master'))
|
||||
|
||||
m = get_any_member(get_cluster_initialized_with_leader(), role='master')
|
||||
assert m.name == 'leader'
|
||||
self.assertEquals(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
|
||||
self.assertEquals(list(get_all_members(get_cluster_initialized_without_leader(), role='master')), [])
|
||||
|
||||
r = list(get_all_members(get_cluster_initialized_with_leader(), role='master'))
|
||||
assert len(r) == 1
|
||||
assert r[0].name == 'leader'
|
||||
self.assertEquals(len(r), 1)
|
||||
self.assertEquals(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'
|
||||
self.assertEquals(len(r), 1)
|
||||
self.assertEquals(r[0].name, 'other')
|
||||
|
||||
r = list(get_all_members(get_cluster_initialized_without_leader(), role='replica'))
|
||||
assert len(r) == 2
|
||||
self.assertEquals(len(list(get_all_members(get_cluster_initialized_without_leader(), role='replica'))), 2)
|
||||
|
||||
@patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
|
||||
@patch('patroni.etcd.Etcd.get_etcd_client', Mock(return_value=None))
|
||||
@patch('requests.get', requests_get)
|
||||
@patch('requests.post', requests_get)
|
||||
def test_members(self):
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(members, ['alpha'])
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
def test_members(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
result = self.runner.invoke(members, ['alpha'])
|
||||
assert '127.0.0.1' in result.output
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_configure(self):
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(configure, [
|
||||
'--dcs',
|
||||
'abc',
|
||||
'-c',
|
||||
'dummy',
|
||||
'-n',
|
||||
'bla',
|
||||
])
|
||||
|
||||
result = self.runner.invoke(configure, ['--dcs', 'abc', '-c', 'dummy', '-n', 'bla'])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
def test_scaffold(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value = self.e
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_not_initialized_without_leader
|
||||
mock_get_dcs.return_value.initialize = Mock(return_value=True)
|
||||
mock_get_dcs.return_value.touch_member = Mock(return_value=True)
|
||||
mock_get_dcs.return_value.attempt_to_acquire_leader = Mock(return_value=True)
|
||||
mock_get_dcs.return_value.delete_cluster = Mock()
|
||||
|
||||
with patch.object(self.e, 'initialize', return_value=False):
|
||||
result = self.runner.invoke(ctl, ['scaffold', 'alpha'])
|
||||
assert result.exception
|
||||
|
||||
with patch.object(mock_get_dcs.return_value, 'touch_member', Mock(return_value=False)):
|
||||
result = self.runner.invoke(ctl, ['scaffold', 'alpha'])
|
||||
assert result.exception
|
||||
|
||||
result = self.runner.invoke(ctl, ['scaffold', 'alpha'])
|
||||
assert result.exit_code == 0
|
||||
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
result = self.runner.invoke(ctl, ['scaffold', 'alpha'])
|
||||
assert result.exception
|
||||
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
def test_list_extended(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value = self.e
|
||||
cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
|
||||
mock_get_dcs.return_value.get_cluster = Mock(return_value=cluster)
|
||||
|
||||
result = self.runner.invoke(ctl, ['list', 'dummy', '--extended'])
|
||||
assert '2100' in result.output
|
||||
assert 'Scheduled restart' in result.output
|
||||
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
@patch('requests.delete', Mock(return_value=MockResponse()))
|
||||
def test_flush(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value = self.e
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
|
||||
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '-r', 'master'], input='y')
|
||||
assert 'No scheduled restart' in result.output
|
||||
|
||||
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '--force'])
|
||||
assert 'Success: flush scheduled restart' in result.output
|
||||
with patch.object(requests, 'delete', return_value=MockResponse(404)):
|
||||
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '--force'])
|
||||
assert 'Failed: flush scheduled restart' in result.output
|
||||
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
def test_pause_cluster(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value = self.e
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
|
||||
with patch('requests.patch', Mock(return_value=MockResponse(200))):
|
||||
result = self.runner.invoke(ctl, ['pause', 'dummy'])
|
||||
assert 'Success' in result.output
|
||||
|
||||
with patch('requests.patch', Mock(return_value=MockResponse(500))):
|
||||
result = self.runner.invoke(ctl, ['pause', 'dummy'])
|
||||
assert 'Failed' in result.output
|
||||
|
||||
with patch('requests.patch', Mock(return_value=MockResponse(200))),\
|
||||
patch('patroni.dcs.Cluster.is_paused', Mock(return_value=True)):
|
||||
result = self.runner.invoke(ctl, ['pause', 'dummy'])
|
||||
assert 'Cluster is already paused' in result.output
|
||||
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
def test_resume_cluster(self, mock_get_dcs):
|
||||
mock_get_dcs.return_value = self.e
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
|
||||
with patch('patroni.dcs.Cluster.is_paused', Mock(return_value=True)):
|
||||
with patch('requests.patch', Mock(return_value=MockResponse(200))):
|
||||
result = self.runner.invoke(ctl, ['resume', 'dummy'])
|
||||
assert 'Success' in result.output
|
||||
|
||||
with patch('requests.patch', Mock(return_value=MockResponse(500))):
|
||||
result = self.runner.invoke(ctl, ['resume', 'dummy'])
|
||||
assert 'Failed' in result.output
|
||||
|
||||
with patch('requests.patch', Mock(return_value=MockResponse(200))),\
|
||||
patch('patroni.dcs.Cluster.is_paused', Mock(return_value=False)):
|
||||
result = self.runner.invoke(ctl, ['resume', 'dummy'])
|
||||
assert 'Cluster is not paused' in result.output
|
||||
|
||||
with patch('requests.patch', Mock(side_effect=Exception)):
|
||||
result = self.runner.invoke(ctl, ['resume', 'dummy'])
|
||||
assert 'Can not find accessible cluster member' in result.output
|
||||
|
||||
+132
-83
@@ -1,20 +1,21 @@
|
||||
import etcd
|
||||
import json
|
||||
import urllib3.util.connection
|
||||
import requests
|
||||
import urllib3
|
||||
import socket
|
||||
import unittest
|
||||
|
||||
from dns.exception import DNSException
|
||||
from mock import Mock, patch
|
||||
from patroni.dcs import Cluster, DCSError, Leader
|
||||
from patroni.etcd import Client, Etcd, EtcdError
|
||||
from patroni.dcs.etcd import AbstractDCS, Client, Cluster, Etcd, EtcdError, DnsCachingResolver
|
||||
from patroni.exceptions import DCSError
|
||||
from urllib3.exceptions import ReadTimeoutError
|
||||
|
||||
|
||||
class MockResponse:
|
||||
class MockResponse(object):
|
||||
|
||||
def __init__(self):
|
||||
self.status_code = 200
|
||||
def __init__(self, status_code=200):
|
||||
self.status_code = status_code
|
||||
self.content = '{}'
|
||||
self.ok = True
|
||||
self.text = ''
|
||||
@@ -24,26 +25,17 @@ class MockResponse:
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
if self.content == 'TimeoutError':
|
||||
raise urllib3.exceptions.TimeoutError
|
||||
if self.content == 'Exception':
|
||||
raise Exception
|
||||
return self.content
|
||||
return self.content.encode('utf-8')
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self.status_code
|
||||
|
||||
@staticmethod
|
||||
def getheader(*args):
|
||||
return ''
|
||||
|
||||
|
||||
class MockPostgresql(Mock):
|
||||
|
||||
def last_operation(self):
|
||||
return '0'
|
||||
|
||||
|
||||
def requests_get(url, **kwargs):
|
||||
members = '[{"id":14855829450254237642,"peerURLs":["http://localhost:2380","http://localhost:7001"],' +\
|
||||
'"name":"default","clientURLs":["http://localhost:2379","http://localhost:4001"]}]'
|
||||
@@ -51,12 +43,9 @@ def requests_get(url, **kwargs):
|
||||
if url.startswith('http://local'):
|
||||
raise requests.exceptions.RequestException()
|
||||
elif ':8011/patroni' in url:
|
||||
response.content = '{"role": "replica", "xlog": {"replayed_location": 0}, "tags": {}}'
|
||||
response.content = '{"role": "replica", "xlog": {"received_location": 0}, "tags": {}}'
|
||||
elif url.endswith('/members'):
|
||||
if url.startswith('http://error'):
|
||||
response.content = '[{}]'
|
||||
else:
|
||||
response.content = members
|
||||
response.content = '[{}]' if url.startswith('http://error') else members
|
||||
elif url.startswith('http://exhibitor'):
|
||||
response.content = '{"servers":["127.0.0.1","127.0.0.2","127.0.0.3"],"port":2181}'
|
||||
else:
|
||||
@@ -65,35 +54,33 @@ def requests_get(url, **kwargs):
|
||||
return response
|
||||
|
||||
|
||||
def etcd_watch(key, index=None, timeout=None, recursive=None):
|
||||
def etcd_watch(self, key, index=None, timeout=None, recursive=None):
|
||||
if timeout == 2.0:
|
||||
raise urllib3.exceptions.TimeoutError
|
||||
raise etcd.EtcdWatchTimedOut
|
||||
elif timeout == 5.0:
|
||||
return etcd.EtcdResult('delete', {})
|
||||
elif timeout == 10.0:
|
||||
raise etcd.EtcdException
|
||||
elif index == 20729:
|
||||
return etcd.EtcdResult('set', {'value': 'postgresql1', 'modifiedIndex': index + 1})
|
||||
elif index == 20731:
|
||||
return etcd.EtcdResult('set', {'value': 'postgresql2', 'modifiedIndex': index + 1})
|
||||
|
||||
|
||||
def etcd_write(key, value, **kwargs):
|
||||
def etcd_write(self, key, value, **kwargs):
|
||||
if key == '/service/exists/leader':
|
||||
raise etcd.EtcdAlreadyExist
|
||||
if key == '/service/test/leader' or key == '/patroni/test/leader':
|
||||
if kwargs.get('prevValue', None) == 'foo' or not kwargs.get('prevExist', True):
|
||||
return True
|
||||
if key in ['/service/test/leader', '/patroni/test/leader'] and \
|
||||
(kwargs.get('prevValue') == 'foo' or not kwargs.get('prevExist', True)):
|
||||
return True
|
||||
raise etcd.EtcdException
|
||||
|
||||
|
||||
def etcd_read(key, **kwargs):
|
||||
def etcd_read(self, key, **kwargs):
|
||||
if key == '/service/noleader/':
|
||||
raise DCSError('noleader')
|
||||
elif key == '/service/nocluster/':
|
||||
raise etcd.EtcdKeyNotFound
|
||||
|
||||
response = {"action": "get", "node": {"key": "/service/batman5", "dir": True, "nodes": [
|
||||
{"key": "/service/batman5/config", "value": '{"foo": "bar"}',
|
||||
"modifiedIndex": 1582, "createdIndex": 1582},
|
||||
{"key": "/service/batman5/failover", "value": "",
|
||||
"modifiedIndex": 1582, "createdIndex": 1582},
|
||||
{"key": "/service/batman5/initialize", "value": "postgresql0",
|
||||
@@ -105,6 +92,8 @@ def etcd_read(key, **kwargs):
|
||||
{"key": "/service/batman5/optime/leader", "value": "2164261704",
|
||||
"modifiedIndex": 20729, "createdIndex": 20729}],
|
||||
"modifiedIndex": 20437, "createdIndex": 20437},
|
||||
{"key": "/service/batman5/sync", "value": '{"leader": "leader"}',
|
||||
"modifiedIndex": 1582, "createdIndex": 1582},
|
||||
{"key": "/service/batman5/members", "dir": True, "nodes": [
|
||||
{"key": "/service/batman5/members/postgresql1",
|
||||
"value": "postgres://replicator:[email protected]:5434/postgres" +
|
||||
@@ -117,108 +106,160 @@ def etcd_read(key, **kwargs):
|
||||
"expiration": "2015-05-15T09:11:09.611860899Z", "ttl": 30,
|
||||
"modifiedIndex": 20730, "createdIndex": 20730}],
|
||||
"modifiedIndex": 1581, "createdIndex": 1581}], "modifiedIndex": 1581, "createdIndex": 1581}}
|
||||
return etcd.EtcdResult(**response)
|
||||
result = etcd.EtcdResult(**response)
|
||||
result.etcd_index = 0
|
||||
return result
|
||||
|
||||
|
||||
class SleepException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MockSRV:
|
||||
port = 2380
|
||||
target = '127.0.0.1'
|
||||
|
||||
|
||||
def dns_query(name, type):
|
||||
def dns_query(name, _):
|
||||
if '-server' not in name or '-ssl' in name:
|
||||
return []
|
||||
if name == '_etcd-server._tcp.blabla':
|
||||
return []
|
||||
elif name == '_etcd-server._tcp.exception':
|
||||
raise DNSException()
|
||||
return [MockSRV()]
|
||||
srv = Mock()
|
||||
srv.port = 2380
|
||||
srv.target.to_text.return_value = 'localhost' if name == '_etcd-server._tcp.foobar' else '127.0.0.1'
|
||||
return [srv]
|
||||
|
||||
|
||||
def socket_getaddrinfo(*args):
|
||||
if args[0] == 'ok':
|
||||
return [(2, 1, 6, '', ('127.0.0.1', 2379)), (2, 1, 6, '', ('127.0.0.1', 2379))]
|
||||
raise socket.error
|
||||
if args[0] in ('ok', 'localhost', '127.0.0.1'):
|
||||
return [(socket.AF_INET, 1, 6, '', ('127.0.0.1', 0)), (socket.AF_INET6, 1, 6, '', ('::1', 0))]
|
||||
raise socket.gaierror
|
||||
|
||||
|
||||
def http_request(method, url, **kwargs):
|
||||
if url == 'http://localhost:2379/timeout':
|
||||
raise ReadTimeoutError(None, None, None)
|
||||
if url == 'http://localhost:2379/v2/machines':
|
||||
ret = MockResponse()
|
||||
ret.content = 'http://localhost:2379,http://localhost:4001'
|
||||
return ret
|
||||
if url == 'http://localhost:2379/':
|
||||
return MockResponse()
|
||||
raise socket.error
|
||||
|
||||
|
||||
class TestDnsCachingResolver(unittest.TestCase):
|
||||
|
||||
@patch('time.sleep', Mock(side_effect=SleepException))
|
||||
@patch('socket.getaddrinfo', Mock(side_effect=socket.gaierror))
|
||||
def test_run(self):
|
||||
r = DnsCachingResolver()
|
||||
self.assertIsNone(r.resolve_async('', 0))
|
||||
r.join()
|
||||
|
||||
|
||||
@patch('dns.resolver.query', dns_query)
|
||||
@patch('socket.getaddrinfo', socket_getaddrinfo)
|
||||
@patch('requests.get', requests_get)
|
||||
class TestClient(unittest.TestCase):
|
||||
|
||||
@patch('dns.resolver.query', dns_query)
|
||||
@patch('socket.getaddrinfo', socket_getaddrinfo)
|
||||
@patch('requests.get', requests_get)
|
||||
def setUp(self):
|
||||
with patch.object(etcd.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'])
|
||||
self.client = Client({'discovery_srv': 'test'})
|
||||
self.client = Client({'srv': 'test', 'retry_timeout': 3}, DnsCachingResolver())
|
||||
self.client.http.request = http_request
|
||||
self.client.http.request_encode_body = http_request
|
||||
|
||||
def test_api_execute(self):
|
||||
def test_machines(self):
|
||||
self.client._base_uri = 'http://localhost:4001'
|
||||
self.client._machines_cache = ['http://localhost:2379']
|
||||
self.client.api_execute('/', 'GET')
|
||||
self.client._update_machines_cache = False
|
||||
self.assertIsNotNone(self.client.machines)
|
||||
self.client._base_uri = 'http://localhost:4001'
|
||||
self.client._machines_cache = []
|
||||
self.assertRaises(etcd.EtcdConnectionFailed, self.client.api_execute, '/', 'GET')
|
||||
self.assertTrue(self.client._update_machines_cache)
|
||||
self.assertIsNotNone(self.client.machines)
|
||||
self.client._update_machines_cache = True
|
||||
machines = None
|
||||
try:
|
||||
machines = self.client.machines
|
||||
self.assertFail()
|
||||
except Exception:
|
||||
self.assertIsNone(machines)
|
||||
|
||||
@patch.object(Client, 'machines')
|
||||
def test_api_execute(self, mock_machines):
|
||||
mock_machines.__get__ = Mock(return_value=['http://localhost:2379'])
|
||||
self.assertRaises(ValueError, self.client.api_execute, '', '')
|
||||
self.client._base_uri = 'http://localhost:4001'
|
||||
self.client._machines_cache = ['http://localhost:2379']
|
||||
self.client.api_execute('/', 'POST', timeout=0)
|
||||
mock_machines.__get__ = Mock(return_value=['http://localhost:2379'])
|
||||
self.client._machines_cache_updated = 0
|
||||
self.client.api_execute('/', 'POST', timeout=0)
|
||||
self.assertRaises(etcd.EtcdWatchTimedOut, self.client.api_execute, '/timeout', 'POST', params={'wait': 'true'})
|
||||
self.assertRaises(etcd.EtcdException, self.client.api_execute, '/', '')
|
||||
self.client._update_machines_cache = True
|
||||
with patch.object(Client, '_load_machines_cache', Mock(side_effect=etcd.EtcdException)):
|
||||
self.assertRaises(etcd.EtcdException, self.client.api_execute, '/', 'GET')
|
||||
|
||||
def test_get_srv_record(self):
|
||||
self.assertEquals(self.client.get_srv_record('blabla'), [])
|
||||
self.assertEquals(self.client.get_srv_record('exception'), [])
|
||||
|
||||
def test__result_from_response(self):
|
||||
response = MockResponse()
|
||||
response.content = 'TimeoutError'
|
||||
self.assertRaises(urllib3.exceptions.TimeoutError, self.client._result_from_response, response)
|
||||
response.content = 'Exception'
|
||||
self.assertRaises(etcd.EtcdException, self.client._result_from_response, response)
|
||||
response.content = b'{}'
|
||||
self.assertRaises(etcd.EtcdException, self.client._result_from_response, response)
|
||||
self.assertEquals(self.client.get_srv_record('_etcd-server._tcp.blabla'), [])
|
||||
self.assertEquals(self.client.get_srv_record('_etcd-server._tcp.exception'), [])
|
||||
|
||||
def test__get_machines_cache_from_srv(self):
|
||||
self.client._get_machines_cache_from_srv('foobar')
|
||||
self.client.get_srv_record = Mock(return_value=[('localhost', 2380)])
|
||||
self.client._get_machines_cache_from_srv('blabla')
|
||||
|
||||
def test__get_machines_cache_from_dns(self):
|
||||
self.client._get_machines_cache_from_dns('error:2379')
|
||||
self.client._get_machines_cache_from_dns('error', 2379)
|
||||
|
||||
def test__load_machines_cache(self):
|
||||
@patch.object(Client, 'machines')
|
||||
def test__load_machines_cache(self, mock_machines):
|
||||
mock_machines.__get__ = Mock(return_value=['http://localhost:2379'])
|
||||
self.client._config = {}
|
||||
self.assertRaises(Exception, self.client._load_machines_cache)
|
||||
self.client._config = {'discovery_srv': 'blabla'}
|
||||
self.client._config = {'srv': 'blabla'}
|
||||
self.assertRaises(etcd.EtcdException, self.client._load_machines_cache)
|
||||
|
||||
@patch.object(socket.socket, 'connect')
|
||||
def test_create_connection_patched(self, mock_connect):
|
||||
self.assertRaises(socket.error, urllib3.util.connection.create_connection, ('fail', 2379))
|
||||
urllib3.util.connection.create_connection(('[localhost]', 2379))
|
||||
mock_connect.side_effect = socket.error
|
||||
self.assertRaises(socket.error, urllib3.util.connection.create_connection, ('[localhost]', 2379),
|
||||
timeout=1, source_address=('localhost', 53333),
|
||||
socket_options=[(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)])
|
||||
|
||||
|
||||
@patch('requests.get', requests_get)
|
||||
@patch('socket.getaddrinfo', socket_getaddrinfo)
|
||||
@patch.object(etcd.Client, 'write', etcd_write)
|
||||
@patch.object(etcd.Client, 'read', etcd_read)
|
||||
@patch.object(etcd.Client, 'delete', Mock(side_effect=etcd.EtcdException))
|
||||
class TestEtcd(unittest.TestCase):
|
||||
|
||||
@patch('socket.getaddrinfo', socket_getaddrinfo)
|
||||
def setUp(self):
|
||||
with patch.object(Client, 'machines') as mock_machines:
|
||||
mock_machines.__get__ = Mock(return_value=['http://localhost:2379', 'http://localhost:4001'])
|
||||
self.etcd = Etcd('foo', {'namespace': '/patroni/', 'ttl': 30, 'host': 'localhost:2379', 'scope': 'test'})
|
||||
self.etcd.client.write = etcd_write
|
||||
self.etcd.client.read = etcd_read
|
||||
self.etcd.client.delete = Mock(side_effect=etcd.EtcdException())
|
||||
self.etcd = Etcd({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 10,
|
||||
'host': 'localhost:2379', 'scope': 'test', 'name': 'foo'})
|
||||
|
||||
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):
|
||||
with patch.object(etcd.Client, 'machines') as mock_machines:
|
||||
with patch.object(Client, 'machines') as mock_machines:
|
||||
mock_machines.__get__ = Mock(side_effect=etcd.EtcdException)
|
||||
with patch('time.sleep', Mock(side_effect=SleepException())):
|
||||
self.assertRaises(SleepException, self.etcd.get_etcd_client, {'discovery_srv': 'test'})
|
||||
with patch('time.sleep', Mock(side_effect=SleepException)):
|
||||
self.assertRaises(SleepException, self.etcd.get_etcd_client,
|
||||
{'discovery_srv': 'test', 'retry_timeout': 10, 'cacert': '1', 'key': '1', 'cert': 1})
|
||||
self.assertRaises(SleepException, self.etcd.get_etcd_client,
|
||||
{'url': 'https://test:2379', 'retry_timeout': 10})
|
||||
self.assertRaises(SleepException, self.etcd.get_etcd_client,
|
||||
{'proxy': 'https://user:password@test:2379', 'retry_timeout': 10})
|
||||
|
||||
def test_get_cluster(self):
|
||||
self.assertIsInstance(self.etcd.get_cluster(), Cluster)
|
||||
@@ -226,11 +267,8 @@ class TestEtcd(unittest.TestCase):
|
||||
cluster = self.etcd.get_cluster()
|
||||
self.assertIsInstance(cluster, Cluster)
|
||||
self.assertIsNone(cluster.leader)
|
||||
|
||||
def test_current_leader(self):
|
||||
self.assertIsInstance(self.etcd.current_leader(), Leader)
|
||||
self.etcd._base_path = '/service/noleader'
|
||||
self.assertIsNone(self.etcd.current_leader())
|
||||
self.assertRaises(EtcdError, self.etcd.get_cluster)
|
||||
|
||||
def test_touch_member(self):
|
||||
self.assertFalse(self.etcd.touch_member('', ''))
|
||||
@@ -259,15 +297,26 @@ class TestEtcd(unittest.TestCase):
|
||||
def test_delete_leader(self):
|
||||
self.assertFalse(self.etcd.delete_leader())
|
||||
|
||||
def test_delete_cluster(self):
|
||||
self.assertFalse(self.etcd.delete_cluster())
|
||||
|
||||
@patch.object(etcd.Client, 'watch', etcd_watch)
|
||||
def test_watch(self):
|
||||
self.etcd.client.watch = etcd_watch
|
||||
self.etcd.watch(0)
|
||||
self.etcd.watch(None, 0)
|
||||
self.etcd.get_cluster()
|
||||
self.etcd.watch(1.5)
|
||||
self.etcd.watch(4.5)
|
||||
self.etcd.watch(9.5)
|
||||
self.etcd.watch(100)
|
||||
self.etcd.watch(20729, 1.5)
|
||||
self.etcd.watch(20729, 4.5)
|
||||
with patch.object(AbstractDCS, 'watch', Mock()):
|
||||
self.etcd.watch(20729, 9.5)
|
||||
|
||||
@patch('patroni.etcd.Etcd.retry', Mock(side_effect=AttributeError("foo")))
|
||||
def test_other_exceptions(self):
|
||||
self.etcd.retry = Mock(side_effect=AttributeError('foo'))
|
||||
self.assertRaises(EtcdError, self.etcd.cancel_initialization)
|
||||
|
||||
def test_set_ttl(self):
|
||||
self.etcd.set_ttl(20)
|
||||
self.assertTrue(self.etcd.watch(None, 1))
|
||||
|
||||
def test_sync_state(self):
|
||||
self.assertFalse(self.etcd.write_sync_state('leader', None))
|
||||
self.assertFalse(self.etcd.delete_sync_state())
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import unittest
|
||||
|
||||
from mock import Mock, patch
|
||||
from patroni.dcs.exhibitor import ExhibitorEnsembleProvider, Exhibitor
|
||||
from patroni.dcs.zookeeper import ZooKeeperError
|
||||
from test_etcd import SleepException, requests_get
|
||||
from test_zookeeper import MockKazooClient
|
||||
|
||||
|
||||
@patch('requests.get', requests_get)
|
||||
@patch('time.sleep', Mock(side_effect=SleepException))
|
||||
class TestExhibitorEnsembleProvider(unittest.TestCase):
|
||||
|
||||
def test_init(self):
|
||||
self.assertRaises(SleepException, ExhibitorEnsembleProvider, ['localhost'], 8181)
|
||||
|
||||
def test_poll(self):
|
||||
self.assertFalse(ExhibitorEnsembleProvider(['exhibitor'], 8181).poll())
|
||||
|
||||
|
||||
class TestExhibitor(unittest.TestCase):
|
||||
|
||||
@patch('requests.get', requests_get)
|
||||
@patch('patroni.dcs.zookeeper.KazooClient', MockKazooClient)
|
||||
def setUp(self):
|
||||
self.e = Exhibitor({'hosts': ['localhost', 'exhibitor'], 'port': 8181, 'scope': 'test',
|
||||
'name': 'foo', 'ttl': 30, 'retry_timeout': 10})
|
||||
|
||||
@patch.object(ExhibitorEnsembleProvider, 'poll', Mock(return_value=True))
|
||||
def test_get_cluster(self):
|
||||
self.assertRaises(ZooKeeperError, self.e.get_cluster)
|
||||
+563
-125
@@ -1,12 +1,18 @@
|
||||
import datetime
|
||||
import etcd
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from mock import Mock, MagicMock, patch
|
||||
from patroni.dcs import Cluster, Failover, Leader, Member
|
||||
from patroni.etcd import Client, Etcd
|
||||
from mock import Mock, MagicMock, PropertyMock, patch
|
||||
from patroni.config import Config
|
||||
from patroni.dcs import Cluster, ClusterConfig, Failover, Leader, Member, get_dcs, SyncState
|
||||
from patroni.dcs.etcd import Client
|
||||
from patroni.exceptions import DCSError, PostgresException
|
||||
from patroni.ha import Ha
|
||||
from patroni.ha import Ha, _MemberStatus
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.utils import tzutc
|
||||
from test_etcd import socket_getaddrinfo, etcd_read, etcd_write, requests_get
|
||||
from test_postgresql import psycopg2_connect
|
||||
|
||||
|
||||
def true(*args, **kwargs):
|
||||
@@ -17,104 +23,139 @@ def false(*args, **kwargs):
|
||||
return False
|
||||
|
||||
|
||||
def get_cluster(initialize, leader, members, failover):
|
||||
return Cluster(initialize, leader, None, members, failover)
|
||||
def get_cluster(initialize, leader, members, failover, sync):
|
||||
return Cluster(initialize, ClusterConfig(1, {1: 2}, 1), leader, 10, members, failover, sync)
|
||||
|
||||
|
||||
def get_cluster_not_initialized_without_leader():
|
||||
return get_cluster(None, None, [], None)
|
||||
return get_cluster(None, None, [], None, SyncState(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_without_leader(leader=False, failover=None, sync=None):
|
||||
m1 = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres',
|
||||
'api_url': 'http://127.0.0.1:8008/patroni', 'xlog_location': 4})
|
||||
l = Leader(0, 0, m1) if leader else None
|
||||
m2 = Member(0, 'other', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres',
|
||||
'api_url': 'http://127.0.0.1:8011/patroni',
|
||||
'state': 'running',
|
||||
'tags': {'clonefrom': True},
|
||||
'scheduled_restart': {'schedule': "2100-01-01 10:53:07.560445+00:00",
|
||||
'postgres_version': '99.0.0'}})
|
||||
syncstate = SyncState(0 if sync else None, sync and sync[0], sync and sync[1])
|
||||
return get_cluster(True, l, [m1, m2], failover, syncstate)
|
||||
|
||||
|
||||
def get_cluster_initialized_with_leader(failover=None):
|
||||
return get_cluster_initialized_without_leader(leader=True, failover=failover)
|
||||
def get_cluster_initialized_with_leader(failover=None, sync=None):
|
||||
return get_cluster_initialized_without_leader(leader=True, failover=failover, sync=sync)
|
||||
|
||||
|
||||
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)
|
||||
return get_cluster(True, l, [l], failover, None)
|
||||
|
||||
|
||||
class MockPostgresql(Mock):
|
||||
def get_node_status(reachable=True, in_recovery=True, xlog_location=10, nofailover=False):
|
||||
def fetch_node_status(e):
|
||||
tags = {}
|
||||
if nofailover:
|
||||
tags['nofailover'] = True
|
||||
return _MemberStatus(e, reachable, in_recovery, xlog_location, tags)
|
||||
return fetch_node_status
|
||||
|
||||
name = 'postgresql0'
|
||||
role = 'replica'
|
||||
state = 'running'
|
||||
connection_string = 'postgres://foo@bar/postgres'
|
||||
|
||||
def is_healthy(self):
|
||||
return True
|
||||
|
||||
def start(self):
|
||||
return True
|
||||
|
||||
def is_healthiest_node(self, members):
|
||||
return True
|
||||
|
||||
def is_leader(self):
|
||||
return True
|
||||
|
||||
def xlog_position(self):
|
||||
return 0
|
||||
|
||||
def last_operation(self):
|
||||
return 0
|
||||
|
||||
def data_directory_empty(self):
|
||||
return False
|
||||
|
||||
def bootstrap(self, *args, **kwargs):
|
||||
return True
|
||||
|
||||
def check_replication_lag(self, last_leader_operation):
|
||||
return True
|
||||
|
||||
def check_recovery_conf(self, leader):
|
||||
return False
|
||||
future_restart_time = datetime.datetime.now(tzutc) + datetime.timedelta(days=5)
|
||||
postmaster_start_time = datetime.datetime.now(tzutc)
|
||||
|
||||
|
||||
class MockPatroni:
|
||||
class MockPatroni(object):
|
||||
|
||||
def __init__(self, p, d):
|
||||
os.environ[Config.PATRONI_CONFIG_VARIABLE] = """
|
||||
restapi:
|
||||
listen: 0.0.0.0:8008
|
||||
bootstrap:
|
||||
users:
|
||||
replicator:
|
||||
password: rep-pass
|
||||
options:
|
||||
- replication
|
||||
postgresql:
|
||||
name: foo
|
||||
data_dir: data/postgresql0
|
||||
pg_rewind:
|
||||
username: postgres
|
||||
password: postgres
|
||||
zookeeper:
|
||||
exhibitor:
|
||||
hosts: [localhost]
|
||||
port: 8181
|
||||
"""
|
||||
self.config = Config()
|
||||
self.postgresql = p
|
||||
self.dcs = d
|
||||
self.api = Mock()
|
||||
self.tags = {}
|
||||
self.tags = {'foo': 'bar'}
|
||||
self.nofailover = None
|
||||
self.replicatefrom = None
|
||||
self.api.connection_string = 'http://127.0.0.1:8008'
|
||||
self.clonefrom = None
|
||||
self.nosync = False
|
||||
self.scheduled_restart = {'schedule': future_restart_time,
|
||||
'postmaster_start_time': str(postmaster_start_time)}
|
||||
|
||||
|
||||
def run_async(func, args=()):
|
||||
func(*args) if args else func()
|
||||
def run_async(self, func, args=()):
|
||||
return func(*args) if args else func()
|
||||
|
||||
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'is_leader', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'xlog_position', Mock(return_value=10))
|
||||
@patch.object(Postgresql, 'call_nowait', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False))
|
||||
@patch.object(Postgresql, 'controldata', Mock(return_value={'Database system identifier': '1234567890'}))
|
||||
@patch.object(Postgresql, 'sync_replication_slots', Mock())
|
||||
@patch.object(Postgresql, 'write_pg_hba', Mock())
|
||||
@patch.object(Postgresql, 'write_pgpass', Mock())
|
||||
@patch.object(Postgresql, 'write_recovery_conf', Mock())
|
||||
@patch.object(Postgresql, 'query', Mock())
|
||||
@patch.object(Postgresql, 'checkpoint', Mock())
|
||||
@patch.object(Postgresql, 'call_nowait', Mock())
|
||||
@patch.object(etcd.Client, 'write', etcd_write)
|
||||
@patch.object(etcd.Client, 'read', etcd_read)
|
||||
@patch.object(etcd.Client, 'delete', Mock(side_effect=etcd.EtcdException))
|
||||
@patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=False))
|
||||
@patch('patroni.async_executor.AsyncExecutor.run_async', run_async)
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
class TestHa(unittest.TestCase):
|
||||
|
||||
@patch('socket.getaddrinfo', socket_getaddrinfo)
|
||||
@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)
|
||||
@patch.object(etcd.Client, 'read', etcd_read)
|
||||
def setUp(self):
|
||||
with patch.object(Client, 'machines') as mock_machines:
|
||||
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
|
||||
self.p = Postgresql({'name': 'postgresql0', 'scope': 'dummy', 'listen': '127.0.0.1:5432',
|
||||
'data_dir': 'data/postgresql0', 'retry_timeout': 10,
|
||||
'maximum_lag_on_failover': 5,
|
||||
'authentication': {'superuser': {'username': 'foo', 'password': 'bar'},
|
||||
'replication': {'username': '', 'password': ''}},
|
||||
'parameters': {'wal_level': 'hot_standby', 'max_replication_slots': 5, 'foo': 'bar',
|
||||
'hot_standby': 'on', 'max_wal_senders': 5, 'wal_keep_segments': 8}})
|
||||
self.p.set_state('running')
|
||||
self.p.set_role('replica')
|
||||
self.p.postmaster_start_time = MagicMock(return_value=str(postmaster_start_time))
|
||||
self.p.can_create_replica_without_replication_connection = MagicMock(return_value=False)
|
||||
self.e = get_dcs({'etcd': {'ttl': 30, 'host': 'ok:2379', 'scope': 'test',
|
||||
'name': 'foo', 'retry_timeout': 10}})
|
||||
self.ha = Ha(MockPatroni(self.p, self.e))
|
||||
self.ha.old_cluster = self.e.get_cluster()
|
||||
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||
self.ha.load_cluster_from_dcs = Mock()
|
||||
self.ha.is_synchronous_mode = false
|
||||
|
||||
def test_update_lock(self):
|
||||
self.p.last_operation = Mock(side_effect=PostgresException(''))
|
||||
self.assertTrue(self.ha.update_lock())
|
||||
self.assertTrue(self.ha.update_lock(True))
|
||||
|
||||
def test_touch_member(self):
|
||||
self.p.xlog_position = Mock(side_effect=Exception)
|
||||
@@ -122,19 +163,25 @@ class TestHa(unittest.TestCase):
|
||||
|
||||
def test_start_as_replica(self):
|
||||
self.p.is_healthy = false
|
||||
self.assertEquals(self.ha.run_cycle(), 'started as a secondary')
|
||||
self.assertEquals(self.ha.run_cycle(), 'starting as a secondary')
|
||||
|
||||
def test_recover_replica_failed(self):
|
||||
self.p.controldata = lambda: {'Database cluster state': 'in production'}
|
||||
self.p.is_healthy = false
|
||||
self.p.follow_the_leader = false
|
||||
self.p.is_running = false
|
||||
self.p.follow = false
|
||||
self.assertEquals(self.ha.run_cycle(), 'starting as a secondary')
|
||||
self.assertEquals(self.ha.run_cycle(), 'failed to start postgres')
|
||||
|
||||
def test_recover_master_failed(self):
|
||||
self.p.follow_the_leader = false
|
||||
self.p.follow = 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')
|
||||
self.p.is_running = false
|
||||
self.p.name = 'leader'
|
||||
self.p.set_role('master')
|
||||
self.p.controldata = lambda: {'Database cluster state': 'in production'}
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
self.assertEquals(self.ha.run_cycle(), 'starting as readonly because i had the session lock')
|
||||
|
||||
@patch('sys.exit', return_value=1)
|
||||
@patch('patroni.ha.Ha.sysid_valid', MagicMock(return_value=True))
|
||||
@@ -144,7 +191,8 @@ class TestHa(unittest.TestCase):
|
||||
|
||||
@patch.object(Cluster, 'is_unlocked', Mock(return_value=False))
|
||||
def test_start_as_readonly(self):
|
||||
self.p.is_leader = self.p.is_healthy = false
|
||||
self.p.is_leader = false
|
||||
self.p.is_healthy = true
|
||||
self.ha.has_lock = true
|
||||
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader because i had the session lock')
|
||||
|
||||
@@ -158,7 +206,7 @@ class TestHa(unittest.TestCase):
|
||||
|
||||
def test_demote_after_failing_to_obtain_lock(self):
|
||||
self.ha.acquire_lock = false
|
||||
self.assertEquals(self.ha.run_cycle(), 'demoted self due after trying and failing to obtain lock')
|
||||
self.assertEquals(self.ha.run_cycle(), 'demoted self after trying and failing to obtain lock')
|
||||
|
||||
def test_follow_new_leader_after_failing_to_obtain_lock(self):
|
||||
self.ha.is_healthiest_node = true
|
||||
@@ -194,26 +242,41 @@ class TestHa(unittest.TestCase):
|
||||
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')
|
||||
self.assertEquals(self.ha.run_cycle(), 'demoted self because failed to update leader lock in DCS')
|
||||
self.p.is_leader = false
|
||||
self.assertEquals(self.ha.run_cycle(), 'not promoting because failed to update leader lock in DCS')
|
||||
|
||||
def test_follow_the_leader(self):
|
||||
def test_follow(self):
|
||||
self.ha.cluster.is_unlocked = 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.ha.patroni.replicatefrom = "foo"
|
||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
||||
|
||||
def test_follow_in_pause(self):
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.ha.is_paused = true
|
||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
|
||||
self.p.is_leader = false
|
||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: no action')
|
||||
|
||||
def test_no_etcd_connection_master_demote(self):
|
||||
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')
|
||||
|
||||
def test_bootstrap_from_leader(self):
|
||||
def test_bootstrap_from_another_member(self):
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
self.p.bootstrap = false
|
||||
self.assertEquals(self.ha.bootstrap(), 'trying to bootstrap from leader')
|
||||
self.assertEquals(self.ha.bootstrap(), 'trying to bootstrap from replica \'other\'')
|
||||
|
||||
def test_bootstrap_waiting_for_leader(self):
|
||||
self.ha.cluster = get_cluster_initialized_without_leader()
|
||||
self.assertEquals(self.ha.bootstrap(), 'waiting for leader to bootstrap')
|
||||
|
||||
def test_bootstrap_without_leader(self):
|
||||
self.ha.cluster = get_cluster_initialized_without_leader()
|
||||
self.p.can_create_replica_without_replication_connection = MagicMock(return_value=True)
|
||||
self.assertEquals(self.ha.bootstrap(), 'trying to bootstrap (without leader)')
|
||||
|
||||
def test_bootstrap_initialize_lock_failed(self):
|
||||
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||
self.assertEquals(self.ha.bootstrap(), 'failed to acquire initialize lock')
|
||||
@@ -230,99 +293,157 @@ class TestHa(unittest.TestCase):
|
||||
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.assertIsNotNone(self.ha.reinitialize())
|
||||
|
||||
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.assertIsNone(self.ha.reinitialize())
|
||||
|
||||
self.ha.has_lock = false
|
||||
self.ha.schedule_reinitialize()
|
||||
self.ha.run_cycle()
|
||||
self.assertIsNotNone(self.ha.reinitialize())
|
||||
|
||||
self.ha.state_handler.name = self.ha.cluster.leader.name
|
||||
self.assertIsNotNone(self.ha.reinitialize())
|
||||
|
||||
def test_restart(self):
|
||||
self.assertEquals(self.ha.restart(), (True, 'restarted successfully'))
|
||||
self.assertEquals(self.ha.restart({}), (True, 'restarted successfully'))
|
||||
self.p.restart = Mock(return_value=None)
|
||||
self.assertEquals(self.ha.restart({}), (False, 'postgres is still starting'))
|
||||
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'))
|
||||
self.assertEquals(self.ha.restart({}), (False, 'restart failed'))
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
self.ha.reinitialize()
|
||||
self.assertEquals(self.ha.restart({}), (False, 'reinitialize already in progress'))
|
||||
with patch.object(self.ha, "restart_matches", return_value=False):
|
||||
self.assertEquals(self.ha.restart({'foo': 'bar'}), (False, "restart conditions are not satisfied"))
|
||||
|
||||
def test_restart_in_progress(self):
|
||||
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')
|
||||
with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)):
|
||||
self.ha.restart({}, run_async=True)
|
||||
self.assertTrue(self.ha.restart_scheduled())
|
||||
self.assertEquals(self.ha.run_cycle(), 'not healthy enough for leader race')
|
||||
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
self.assertEquals(self.ha.run_cycle(), 'restart in progress')
|
||||
self.ha.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.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')
|
||||
self.ha.update_lock = false
|
||||
self.assertEquals(self.ha.run_cycle(), 'failed to update leader lock during restart')
|
||||
|
||||
@patch('requests.get', requests_get)
|
||||
@patch('time.sleep', Mock())
|
||||
def test_manual_failover_from_leader(self):
|
||||
self.ha.fetch_node_status = get_node_status()
|
||||
self.ha.has_lock = true
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', ''))
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', '', None))
|
||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', MockPostgresql.name))
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None))
|
||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla'))
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla', None))
|
||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
f = Failover(0, MockPostgresql.name, '')
|
||||
f = Failover(0, self.p.name, '', None)
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(f)
|
||||
self.assertEquals(self.ha.run_cycle(), 'manual failover: demoting myself')
|
||||
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {'nofailover': 'True'})
|
||||
self.ha.fetch_node_status = get_node_status(nofailover=True)
|
||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
self.ha.fetch_node_status = get_node_status(xlog_location=1)
|
||||
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.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, None))
|
||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
|
||||
# Failover scheduled time must include timezone
|
||||
scheduled = datetime.datetime.now()
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.ha.run_cycle()
|
||||
|
||||
scheduled = datetime.datetime.utcnow().replace(tzinfo=tzutc)
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
scheduled = scheduled + datetime.timedelta(seconds=30)
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
scheduled = scheduled + datetime.timedelta(seconds=-600)
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
scheduled = None
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
@patch('requests.get', requests_get)
|
||||
def test_manual_failover_from_leader_in_pause(self):
|
||||
self.ha.has_lock = true
|
||||
self.ha.is_paused = true
|
||||
scheduled = datetime.datetime.now()
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||
self.assertEquals('PAUSE: no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, '', None))
|
||||
self.assertEquals('PAUSE: no action. i am the leader with the lock', self.ha.run_cycle())
|
||||
|
||||
@patch('requests.get', requests_get)
|
||||
@patch('time.sleep', Mock())
|
||||
def test_manual_failover_process_no_leader(self):
|
||||
self.p.is_leader = false
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', MockPostgresql.name))
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', self.p.name, None))
|
||||
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'leader'))
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'leader', None))
|
||||
self.p.set_role('replica')
|
||||
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {}) # accessible, in_recovery
|
||||
self.ha.fetch_node_status = get_node_status() # 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.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, self.p.name, '', None))
|
||||
self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
||||
self.ha.fetch_node_status = lambda e: (e, False, True, 0, {}) # inaccessible, in_recovery
|
||||
self.ha.fetch_node_status = get_node_status(reachable=False) # inaccessible, in_recovery
|
||||
self.p.set_role('replica')
|
||||
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||
# set failover flag to True for all members of the cluster
|
||||
# this should elect the current member, as we are not going to call the API for it.
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other'))
|
||||
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {'nofailover': 'True'}) # accessible, in_recovery
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None))
|
||||
self.ha.fetch_node_status = get_node_status(nofailover=True) # accessible, in_recovery
|
||||
self.p.set_role('replica')
|
||||
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||
# same as previous, but set the current member to nofailover. In no case it should be elected as a leader
|
||||
self.ha.patroni.nofailover = True
|
||||
self.assertEquals(self.ha.run_cycle(), 'following a different leader because I am not allowed to promote')
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
def test_manual_failover_process_no_leader_in_pause(self):
|
||||
self.ha.is_paused = true
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None))
|
||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', '', None))
|
||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', 'blabla', None))
|
||||
self.assertEquals('PAUSE: acquired session lock as a leader', self.ha.run_cycle())
|
||||
self.p.is_leader = false
|
||||
self.p.set_role('replica')
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', self.p.name, None))
|
||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: promoted self to leader by acquiring session lock')
|
||||
|
||||
def test_is_healthiest_node(self):
|
||||
self.ha.state_handler.is_leader = false
|
||||
self.ha.patroni.nofailover = False
|
||||
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {})
|
||||
self.ha.fetch_node_status = get_node_status()
|
||||
self.assertTrue(self.ha.is_healthiest_node())
|
||||
with patch('patroni.postgresql.Postgresql.is_starting', return_value=True):
|
||||
self.assertFalse(self.ha.is_healthiest_node())
|
||||
self.ha.is_paused = true
|
||||
self.assertFalse(self.ha.is_healthiest_node())
|
||||
|
||||
def test__is_healthiest_node(self):
|
||||
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
self.p.is_leader = false
|
||||
self.ha.fetch_node_status = lambda e: (e, True, True, 0, {}) # accessible, in_recovery
|
||||
self.ha.fetch_node_status = get_node_status() # 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.ha.fetch_node_status = get_node_status(in_recovery=False) # 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.ha.fetch_node_status = get_node_status(xlog_location=11) # accessible, in_recovery, xlog location ahead
|
||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
with patch('patroni.postgresql.Postgresql.xlog_position', return_value=1):
|
||||
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
|
||||
@@ -333,3 +454,320 @@ class TestHa(unittest.TestCase):
|
||||
self.ha.fetch_node_status(member)
|
||||
member = Member(0, 'test', 1, {'api_url': 'http://localhost:8011/patroni'})
|
||||
self.ha.fetch_node_status(member)
|
||||
|
||||
def test_post_recover(self):
|
||||
self.p.is_running = false
|
||||
self.ha.has_lock = true
|
||||
self.assertEqual(self.ha.post_recover(), 'removed leader key after trying and failing to start postgres')
|
||||
self.ha.has_lock = false
|
||||
self.assertEqual(self.ha.post_recover(), 'failed to start postgres')
|
||||
self.p.is_running = true
|
||||
self.assertIsNone(self.ha.post_recover())
|
||||
|
||||
def test_schedule_future_restart(self):
|
||||
self.ha.patroni.scheduled_restart = {}
|
||||
# do the restart 2 times. The first one should succeed, the second one should fail
|
||||
self.assertTrue(self.ha.schedule_future_restart({'schedule': future_restart_time}))
|
||||
self.assertFalse(self.ha.schedule_future_restart({'schedule': future_restart_time}))
|
||||
|
||||
def test_delete_future_restarts(self):
|
||||
self.ha.delete_future_restart()
|
||||
|
||||
def test_evaluate_scheduled_restart(self):
|
||||
self.p.postmaster_start_time = Mock(return_value=str(postmaster_start_time))
|
||||
# restart already in progres
|
||||
with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)):
|
||||
self.assertIsNone(self.ha.evaluate_scheduled_restart())
|
||||
# restart while the postmaster has been already restarted, fails
|
||||
with patch.object(self.ha,
|
||||
'future_restart_scheduled',
|
||||
Mock(return_value={'postmaster_start_time':
|
||||
str(postmaster_start_time - datetime.timedelta(days=1)),
|
||||
'schedule': str(future_restart_time)})):
|
||||
self.assertIsNone(self.ha.evaluate_scheduled_restart())
|
||||
with patch.object(self.ha,
|
||||
'future_restart_scheduled',
|
||||
Mock(return_value={'postmaster_start_time': str(postmaster_start_time),
|
||||
'schedule': str(future_restart_time)})):
|
||||
with patch.object(self.ha,
|
||||
'should_run_scheduled_action', Mock(return_value=True)):
|
||||
# restart in the future, ok
|
||||
self.assertIsNotNone(self.ha.evaluate_scheduled_restart())
|
||||
with patch.object(self.ha, 'restart', Mock(return_value=(False, "Test"))):
|
||||
# restart in the future, bit the actual restart failed
|
||||
self.assertIsNone(self.ha.evaluate_scheduled_restart())
|
||||
|
||||
def test_scheduled_restart(self):
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
with patch.object(self.ha, "evaluate_scheduled_restart", Mock(return_value="restart scheduled")):
|
||||
self.assertEquals(self.ha.run_cycle(), "restart scheduled")
|
||||
|
||||
def test_restart_matches(self):
|
||||
self.p._role = 'replica'
|
||||
self.p.server_version = 90500
|
||||
self.p._pending_restart = True
|
||||
self.assertFalse(self.ha.restart_matches("master", "9.5.0", True))
|
||||
self.assertFalse(self.ha.restart_matches("replica", "9.4.3", True))
|
||||
self.p._pending_restart = False
|
||||
self.assertFalse(self.ha.restart_matches("replica", "9.5.2", True))
|
||||
self.assertTrue(self.ha.restart_matches("replica", "9.5.2", False))
|
||||
|
||||
def test_process_healthy_cluster_in_pause(self):
|
||||
self.p.is_leader = false
|
||||
self.ha.is_paused = true
|
||||
self.p.name = 'leader'
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: removed leader lock because postgres is not running as master')
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None))
|
||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: waiting to become master after promote...')
|
||||
|
||||
def test_postgres_unhealthy_in_pause(self):
|
||||
self.ha.is_paused = true
|
||||
self.p.is_healthy = false
|
||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: postgres is not running')
|
||||
self.ha.has_lock = true
|
||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: removed leader lock because postgres is not running')
|
||||
|
||||
def test_no_etcd_connection_in_pause(self):
|
||||
self.ha.is_paused = true
|
||||
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: DCS is not accessible')
|
||||
|
||||
@patch('patroni.ha.Ha.update_lock', return_value=True)
|
||||
@patch('patroni.ha.Ha.demote')
|
||||
def test_starting_timeout(self, demote, update_lock):
|
||||
def check_calls(seq):
|
||||
for mock, called in seq:
|
||||
if called:
|
||||
mock.assert_called_once()
|
||||
else:
|
||||
mock.assert_not_called()
|
||||
mock.reset_mock()
|
||||
self.ha.has_lock = true
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
self.p.check_for_startup = true
|
||||
self.p.time_in_state = lambda: 30
|
||||
self.assertEquals(self.ha.run_cycle(), 'PostgreSQL is still starting up, 270 seconds until timeout')
|
||||
check_calls([(update_lock, True), (demote, False)])
|
||||
|
||||
self.p.time_in_state = lambda: 350
|
||||
self.ha.fetch_node_status = get_node_status(reachable=False) # inaccessible, in_recovery
|
||||
self.assertEquals(self.ha.run_cycle(),
|
||||
'master start has timed out, but continuing to wait because failover is not possible')
|
||||
check_calls([(update_lock, True), (demote, False)])
|
||||
|
||||
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
||||
self.assertEquals(self.ha.run_cycle(), 'stopped PostgreSQL because of startup timeout')
|
||||
check_calls([(update_lock, True), (demote, True)])
|
||||
|
||||
update_lock.return_value = False
|
||||
self.assertEquals(self.ha.run_cycle(), 'stopped PostgreSQL while starting up because leader key was lost')
|
||||
check_calls([(update_lock, True), (demote, True)])
|
||||
|
||||
self.ha.has_lock = false
|
||||
self.p.is_leader = false
|
||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
||||
check_calls([(update_lock, False), (demote, False)])
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
def test_manual_failover_while_starting(self):
|
||||
self.ha.has_lock = true
|
||||
self.p.check_for_startup = true
|
||||
f = Failover(0, self.p.name, '', None)
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(f)
|
||||
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
||||
self.assertEquals(self.ha.run_cycle(), 'manual failover: demoting myself')
|
||||
|
||||
@patch('patroni.ha.Ha.demote')
|
||||
def test_failover_immediately_on_zero_master_start_timeout(self, demote):
|
||||
self.p.is_running = false
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
self.ha.patroni.config.set_dynamic_configuration({'master_start_timeout': 0})
|
||||
self.ha.has_lock = true
|
||||
self.ha.update_lock = true
|
||||
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
||||
self.assertEquals(self.ha.run_cycle(), 'stopped PostgreSQL to fail over after a crash')
|
||||
demote.assert_called_once()
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
@patch('patroni.postgresql.Postgresql.follow')
|
||||
def test_demote_immediate(self, follow):
|
||||
self.ha.has_lock = true
|
||||
self.e.get_cluster = Mock(return_value=get_cluster_initialized_without_leader())
|
||||
self.ha.demote('immediate')
|
||||
follow.assert_called_once_with(None, None, True, None, True)
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
def test_process_sync_replication(self):
|
||||
self.ha.has_lock = true
|
||||
mock_set_sync = self.p.set_synchronous_standby = Mock()
|
||||
self.p.name = 'leader'
|
||||
|
||||
# Test sync key removed when sync mode disabled
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
|
||||
with patch.object(self.ha.dcs, 'delete_sync_state') as mock_delete_sync:
|
||||
self.ha.run_cycle()
|
||||
mock_delete_sync.assert_called_once()
|
||||
mock_set_sync.assert_called_once_with(None)
|
||||
|
||||
mock_set_sync.reset_mock()
|
||||
# Test sync key not touched when not there
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
with patch.object(self.ha.dcs, 'delete_sync_state') as mock_delete_sync:
|
||||
self.ha.run_cycle()
|
||||
mock_delete_sync.assert_not_called()
|
||||
mock_set_sync.assert_called_once_with(None)
|
||||
|
||||
mock_set_sync.reset_mock()
|
||||
|
||||
self.ha.is_synchronous_mode = true
|
||||
|
||||
# Test sync standby not touched when picking the same node
|
||||
self.p.pick_synchronous_standby = Mock(return_value=('other', True))
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
|
||||
self.ha.run_cycle()
|
||||
mock_set_sync.assert_not_called()
|
||||
|
||||
mock_set_sync.reset_mock()
|
||||
|
||||
# Test sync standby is replaced when switching standbys
|
||||
self.p.pick_synchronous_standby = Mock(return_value=('other2', False))
|
||||
self.ha.dcs.write_sync_state = Mock(return_value=True)
|
||||
self.ha.run_cycle()
|
||||
mock_set_sync.assert_called_once_with('other2')
|
||||
|
||||
mock_set_sync.reset_mock()
|
||||
# Test sync standby is not disabled when updating dcs fails
|
||||
self.ha.dcs.write_sync_state = Mock(return_value=False)
|
||||
self.ha.run_cycle()
|
||||
mock_set_sync.assert_not_called()
|
||||
|
||||
mock_set_sync.reset_mock()
|
||||
# Test changing sync standby
|
||||
self.ha.dcs.write_sync_state = Mock(return_value=True)
|
||||
self.ha.dcs.get_cluster = Mock(return_value=get_cluster_initialized_with_leader(sync=('leader', 'other')))
|
||||
# self.ha.cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
|
||||
self.p.pick_synchronous_standby = Mock(return_value=('other2', True))
|
||||
self.ha.run_cycle()
|
||||
self.ha.dcs.get_cluster.assert_called_once()
|
||||
self.assertEquals(self.ha.dcs.write_sync_state.call_count, 2)
|
||||
|
||||
# Test updating sync standby key failed due to race
|
||||
self.ha.dcs.write_sync_state = Mock(side_effect=[True, False])
|
||||
self.ha.run_cycle()
|
||||
self.assertEquals(self.ha.dcs.write_sync_state.call_count, 2)
|
||||
|
||||
# Test changing sync standby failed due to race
|
||||
self.ha.dcs.write_sync_state = Mock(return_value=True)
|
||||
self.ha.dcs.get_cluster = Mock(return_value=get_cluster_initialized_with_leader(sync=('somebodyelse', None)))
|
||||
self.ha.run_cycle()
|
||||
self.assertEquals(self.ha.dcs.write_sync_state.call_count, 1)
|
||||
|
||||
def test_sync_replication_become_master(self):
|
||||
self.ha.is_synchronous_mode = true
|
||||
|
||||
mock_set_sync = self.p.set_synchronous_standby = Mock()
|
||||
self.p.is_leader = false
|
||||
self.p.set_role('replica')
|
||||
self.ha.has_lock = true
|
||||
mock_write_sync = self.ha.dcs.write_sync_state = Mock(return_value=True)
|
||||
self.p.name = 'leader'
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(sync=('other', None))
|
||||
|
||||
# When we just became master nobody is sync
|
||||
self.assertEquals(self.ha.enforce_master_role('msg', 'promote msg'), 'promote msg')
|
||||
mock_set_sync.assert_called_once_with(None)
|
||||
mock_write_sync.assert_called_once_with('leader', None, index=0)
|
||||
|
||||
mock_set_sync.reset_mock()
|
||||
|
||||
# When we just became master nobody is sync
|
||||
self.p.set_role('replica')
|
||||
mock_write_sync.return_value = False
|
||||
self.assertTrue(self.ha.enforce_master_role('msg', 'promote msg') != 'promote msg')
|
||||
mock_set_sync.assert_not_called()
|
||||
|
||||
def test_unhealthy_sync_mode(self):
|
||||
self.ha.is_synchronous_mode = true
|
||||
|
||||
self.p.is_leader = false
|
||||
self.p.set_role('replica')
|
||||
self.p.name = 'other'
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(sync=('leader', 'other2'))
|
||||
mock_write_sync = self.ha.dcs.write_sync_state = Mock(return_value=True)
|
||||
mock_acquire = self.ha.acquire_lock = Mock(return_value=True)
|
||||
mock_follow = self.p.follow = Mock()
|
||||
mock_promote = self.p.promote = Mock()
|
||||
|
||||
# If we don't match the sync replica we are not allowed to acquire lock
|
||||
self.ha.run_cycle()
|
||||
mock_acquire.assert_not_called()
|
||||
mock_follow.assert_called_once()
|
||||
self.assertEquals(mock_follow.call_args[0][0], None)
|
||||
mock_write_sync.assert_not_called()
|
||||
|
||||
mock_follow.reset_mock()
|
||||
# If we do match we will try to promote
|
||||
self.ha._is_healthiest_node = true
|
||||
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(sync=('leader', 'other'))
|
||||
self.ha.run_cycle()
|
||||
mock_acquire.assert_called_once()
|
||||
mock_follow.assert_not_called()
|
||||
mock_promote.assert_called_once()
|
||||
mock_write_sync.assert_called_once_with('other', None, index=0)
|
||||
|
||||
@patch('time.sleep')
|
||||
def test_disable_sync_when_restarting(self, mock_sleep):
|
||||
self.ha.is_synchronous_mode = true
|
||||
|
||||
self.p.name = 'other'
|
||||
self.p.is_leader = false
|
||||
self.p.set_role('replica')
|
||||
mock_restart = self.p.restart = Mock(return_value=True)
|
||||
self.ha.cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
|
||||
self.ha.touch_member = Mock(return_value=True)
|
||||
self.ha.dcs.get_cluster = Mock(side_effect=[
|
||||
get_cluster_initialized_with_leader(sync=('leader', syncstandby))
|
||||
for syncstandby in ['other', None]])
|
||||
|
||||
self.ha.restart({})
|
||||
|
||||
mock_restart.assert_called_once()
|
||||
mock_sleep.assert_called()
|
||||
|
||||
# Restart is still called when DCS connection fails
|
||||
mock_restart.reset_mock()
|
||||
self.ha.dcs.get_cluster = Mock(side_effect=DCSError("foo"))
|
||||
self.ha.restart({})
|
||||
|
||||
mock_restart.assert_called_once()
|
||||
|
||||
# We don't try to fetch the cluster state when touch_member fails
|
||||
mock_restart.reset_mock()
|
||||
self.ha.dcs.get_cluster.reset_mock()
|
||||
self.ha.touch_member = Mock(return_value=False)
|
||||
|
||||
self.ha.restart({})
|
||||
|
||||
mock_restart.assert_called_once()
|
||||
self.ha.dcs.get_cluster.assert_not_called()
|
||||
|
||||
def test_effective_tags(self):
|
||||
self.ha._disable_sync = True
|
||||
self.assertEquals(self.ha.get_effective_tags(), {'foo': 'bar', 'nosync': True})
|
||||
self.ha._disable_sync = False
|
||||
self.assertEquals(self.ha.get_effective_tags(), {'foo': 'bar'})
|
||||
|
||||
def test_restore_cluster_config(self):
|
||||
self.ha.cluster.config.data.clear()
|
||||
self.ha.has_lock = true
|
||||
self.ha.cluster.is_unlocked = false
|
||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||
|
||||
def test_watch(self):
|
||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||
self.ha.watch(0)
|
||||
|
||||
def test_wakup(self):
|
||||
self.ha.wakeup()
|
||||
|
||||
+108
-39
@@ -1,82 +1,151 @@
|
||||
import etcd
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
import yaml
|
||||
|
||||
from mock import Mock, patch
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.api import RestApiServer
|
||||
from patroni.async_executor import AsyncExecutor
|
||||
from patroni.etcd import Etcd
|
||||
from patroni import Patroni, main
|
||||
from patroni.zookeeper import ZooKeeper
|
||||
from patroni.dcs.etcd import Client
|
||||
from patroni.exceptions import DCSError
|
||||
from patroni import Patroni, main as _main, patroni_main
|
||||
from six.moves import BaseHTTPServer
|
||||
from test_etcd import Client, SleepException, etcd_read, etcd_write
|
||||
from test_etcd import SleepException, etcd_read, etcd_write
|
||||
from test_postgresql import Postgresql, psycopg2_connect
|
||||
from test_zookeeper import MockKazooClient
|
||||
|
||||
|
||||
def time_sleep(*args):
|
||||
raise SleepException()
|
||||
class MockFrozenImporter(object):
|
||||
|
||||
toc = set(['patroni.dcs.etcd'])
|
||||
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
@patch.object(Postgresql, 'write_pg_hba', Mock())
|
||||
@patch.object(Postgresql, '_write_postgresql_conf', Mock())
|
||||
@patch.object(Postgresql, 'write_recovery_conf', Mock())
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'call_nowait', Mock())
|
||||
@patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock())
|
||||
@patch.object(AsyncExecutor, 'run', Mock())
|
||||
@patch.object(etcd.Client, 'write', etcd_write)
|
||||
@patch.object(etcd.Client, 'read', etcd_read)
|
||||
class TestPatroni(unittest.TestCase):
|
||||
|
||||
@patch.object(Client, 'machines')
|
||||
def setUp(self, mock_machines):
|
||||
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
|
||||
self.touched = False
|
||||
self.init_cancelled = False
|
||||
@patch('pkgutil.get_importer', Mock(return_value=MockFrozenImporter()))
|
||||
@patch('sys.frozen', Mock(return_value=True), create=True)
|
||||
@patch.object(etcd.Client, 'read', etcd_read)
|
||||
def setUp(self):
|
||||
RestApiServer._BaseServer__is_shut_down = Mock()
|
||||
RestApiServer._BaseServer__shutdown_request = True
|
||||
RestApiServer.socket = 0
|
||||
with open('postgres0.yml', 'r') as f:
|
||||
config = yaml.load(f)
|
||||
self.p = Patroni(config)
|
||||
self.p.ha.dcs.client.write = etcd_write
|
||||
self.p.ha.dcs.client.read = etcd_read
|
||||
with patch.object(Client, 'machines') as mock_machines:
|
||||
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
|
||||
sys.argv = ['patroni.py', 'postgres0.yml']
|
||||
self.p = Patroni()
|
||||
|
||||
@patch('patroni.zookeeper.KazooClient', MockKazooClient())
|
||||
def test_get_dcs(self):
|
||||
self.assertIsInstance(self.p.get_dcs('', {'zookeeper': {'scope': '', 'hosts': ''}}), ZooKeeper)
|
||||
self.assertRaises(Exception, self.p.get_dcs, '', {})
|
||||
@patch('patroni.dcs.AbstractDCS.get_cluster', Mock(side_effect=[None, DCSError('foo'), None]))
|
||||
def test_load_dynamic_configuration(self):
|
||||
self.p.config._dynamic_configuration = {}
|
||||
self.p.load_dynamic_configuration()
|
||||
self.p.load_dynamic_configuration()
|
||||
|
||||
@patch('time.sleep', Mock(side_effect=SleepException()))
|
||||
@patch.object(Etcd, 'delete_leader', Mock())
|
||||
@patch('time.sleep', Mock(side_effect=SleepException))
|
||||
@patch.object(etcd.Client, 'delete', Mock())
|
||||
@patch.object(Client, 'machines')
|
||||
def test_patroni_main(self, mock_machines):
|
||||
main()
|
||||
sys.argv = ['patroni.py', 'postgres0.yml']
|
||||
def test_patroni_patroni_main(self, mock_machines):
|
||||
with patch('subprocess.call', Mock(return_value=1)):
|
||||
sys.argv = ['patroni.py', 'postgres0.yml']
|
||||
|
||||
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
|
||||
with patch.object(Patroni, 'run', Mock(side_effect=SleepException())):
|
||||
self.assertRaises(SleepException, main)
|
||||
with patch.object(Patroni, 'run', Mock(side_effect=KeyboardInterrupt())):
|
||||
main()
|
||||
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
|
||||
with patch.object(Patroni, 'run', Mock(side_effect=SleepException)):
|
||||
self.assertRaises(SleepException, patroni_main)
|
||||
with patch.object(Patroni, 'run', Mock(side_effect=KeyboardInterrupt())):
|
||||
with patch('patroni.ha.Ha.is_paused', Mock(return_value=True)):
|
||||
patroni_main()
|
||||
|
||||
@patch('time.sleep', Mock(side_effect=SleepException()))
|
||||
@patch('os.getpid')
|
||||
@patch('subprocess.Popen', )
|
||||
@patch('patroni.patroni_main', Mock())
|
||||
def test_patroni_main(self, mock_popen, mock_getpid):
|
||||
mock_getpid.return_value = 2
|
||||
_main()
|
||||
|
||||
with patch('sys.frozen', Mock(return_value=True), create=True):
|
||||
sys.argv = ['/patroni', 'pg_ctl_start', 'postgres', '-D', '/data', '--max_connections=100']
|
||||
_main()
|
||||
|
||||
mock_getpid.return_value = 1
|
||||
|
||||
def mock_signal(signo, handler):
|
||||
handler(signo, None)
|
||||
|
||||
with patch('signal.signal', mock_signal):
|
||||
with patch('os.waitpid', Mock(side_effect=[(1, 0), (0, 0)])):
|
||||
_main()
|
||||
with patch('os.waitpid', Mock(side_effect=OSError)):
|
||||
_main()
|
||||
|
||||
ref = {'passtochild': lambda signo, stack_frame: 0}
|
||||
|
||||
def mock_sighup(signo, handler):
|
||||
if signo == signal.SIGHUP:
|
||||
ref['passtochild'] = handler
|
||||
|
||||
def mock_wait():
|
||||
ref['passtochild'](0, None)
|
||||
|
||||
mock_popen.return_value.wait = mock_wait
|
||||
with patch('signal.signal', mock_sighup), patch('os.kill', Mock()):
|
||||
self.assertIsNone(_main())
|
||||
|
||||
@patch('patroni.config.Config.save_cache', Mock())
|
||||
@patch('patroni.config.Config.reload_local_configuration', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'state', PropertyMock(return_value='running'))
|
||||
def test_run(self):
|
||||
self.p.ha.dcs.watch = time_sleep
|
||||
self.assertRaises(SleepException, self.p.run)
|
||||
|
||||
self.p.ha.state_handler.is_leader = Mock(return_value=False)
|
||||
self.p.sighup_handler()
|
||||
self.p.ha.dcs.watch = Mock(side_effect=SleepException)
|
||||
self.p.api.start = Mock()
|
||||
self.p.config._dynamic_configuration = {}
|
||||
self.assertRaises(SleepException, self.p.run)
|
||||
with patch('patroni.config.Config.set_dynamic_configuration', Mock(return_value=True)):
|
||||
self.assertRaises(SleepException, self.p.run)
|
||||
with patch('patroni.postgresql.Postgresql.data_directory_empty', Mock(return_value=False)):
|
||||
self.assertRaises(SleepException, self.p.run)
|
||||
|
||||
def test_sigterm_handler(self):
|
||||
self.assertRaises(SystemExit, self.p.sigterm_handler)
|
||||
|
||||
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.dcs.loop_wait - 1
|
||||
self.p.schedule_next_run()
|
||||
|
||||
def test_noloadbalance(self):
|
||||
self.p.tags['noloadbalance'] = True
|
||||
self.assertTrue(self.p.noloadbalance)
|
||||
|
||||
def test_nofailover(self):
|
||||
self.p.tags['nofailover'] = True
|
||||
self.assertTrue(self.p.nofailover)
|
||||
self.p.tags['nofailover'] = None
|
||||
self.assertFalse(self.p.nofailover)
|
||||
|
||||
def test_replicatefrom(self):
|
||||
self.assertIsNone(self.p.replicatefrom)
|
||||
self.p.tags['replicatefrom'] = 'foo'
|
||||
self.assertEqual(self.p.replicatefrom, 'foo')
|
||||
|
||||
def test_reload_config(self):
|
||||
self.p.reload_config()
|
||||
self.p.get_tags = Mock(side_effect=Exception)
|
||||
self.p.reload_config()
|
||||
|
||||
def test_nosync(self):
|
||||
self.p.tags['nosync'] = True
|
||||
self.assertTrue(self.p.nosync)
|
||||
self.p.tags['nosync'] = None
|
||||
self.assertFalse(self.p.nosync)
|
||||
|
||||
+480
-176
@@ -2,24 +2,19 @@ import mock # for the mock.call method, importing it without a namespace breaks
|
||||
import os
|
||||
import psycopg2
|
||||
import shutil
|
||||
import subprocess
|
||||
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, SyncState
|
||||
from patroni.exceptions import PostgresException, PostgresConnectionException
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.postgresql import Postgresql, STATE_REJECT, STATE_NO_RESPONSE
|
||||
from patroni.utils import RetryFailedError
|
||||
from test_ha import false
|
||||
import subprocess
|
||||
from six.moves import builtins
|
||||
from threading import Thread
|
||||
|
||||
|
||||
def is_file_raise_on_backup(*args, **kwargs):
|
||||
if args[0].endswith('.backup'):
|
||||
raise Exception("foo")
|
||||
|
||||
|
||||
class MockCursor:
|
||||
class MockCursor(object):
|
||||
|
||||
def __init__(self, connection):
|
||||
self.connection = connection
|
||||
@@ -33,25 +28,23 @@ class MockCursor:
|
||||
raise RetryFailedError('retry')
|
||||
elif sql.startswith('SELECT slot_name'):
|
||||
self.results = [('blabla',), ('foobar',)]
|
||||
elif sql.startswith('SELECT pg_xlog_location_diff'):
|
||||
elif sql.startswith('SELECT CASE WHEN pg_is_in_recovery()'):
|
||||
self.results = [(0,)]
|
||||
elif sql == 'SELECT pg_is_in_recovery()':
|
||||
self.results = [(False, )]
|
||||
elif sql.startswith('SELECT to_char(pg_postmaster_start_time'):
|
||||
self.results = [('', True, '', '', '', False)]
|
||||
elif sql.startswith('WITH replication_info AS ('):
|
||||
replication_info = '[{"application_name":"walreceiver","client_addr":"1.2.3.4",' +\
|
||||
'"state":"streaming","sync_state":"async","sync_priority":0}]'
|
||||
self.results = [('', True, '', '', '', '', False, replication_info)]
|
||||
elif sql.startswith('SELECT name, setting'):
|
||||
self.results = [('wal_segment_size', '2048', '8kB', 'integer', 'internal'),
|
||||
('search_path', 'public', None, 'string', 'user'),
|
||||
('port', '5433', None, 'integer', 'postmaster'),
|
||||
('listen_addresses', '*', None, 'string', 'postmaster'),
|
||||
('autovacuum', 'on', None, 'bool', 'sighup'),
|
||||
('wal_level', 'replica', None, 'enum', 'postmaster')]
|
||||
else:
|
||||
self.results = [(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)]
|
||||
self.results = [(None, None, None, None, None, None, None, None, None, None)]
|
||||
|
||||
def fetchone(self):
|
||||
return self.results[0]
|
||||
@@ -59,9 +52,6 @@ class MockCursor:
|
||||
def fetchall(self):
|
||||
return self.results
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def __iter__(self):
|
||||
for i in self.results:
|
||||
yield i
|
||||
@@ -73,8 +63,9 @@ class MockCursor:
|
||||
pass
|
||||
|
||||
|
||||
class MockConnect(Mock):
|
||||
class MockConnect(object):
|
||||
|
||||
server_version = '99999'
|
||||
autocommit = False
|
||||
closed = 0
|
||||
|
||||
@@ -87,6 +78,10 @@ class MockConnect(Mock):
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def close():
|
||||
pass
|
||||
|
||||
|
||||
def pg_controldata_string(*args, **kwargs):
|
||||
return b"""
|
||||
@@ -154,145 +149,235 @@ def psycopg2_connect(*args, **kwargs):
|
||||
return MockConnect()
|
||||
|
||||
|
||||
def fake_listdir(path):
|
||||
return ["a", "b", "c"] if path.endswith('pg_xlog/archive_status') else []
|
||||
|
||||
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
@patch('shutil.copy', Mock())
|
||||
class TestPostgresql(unittest.TestCase):
|
||||
_PARAMETERS = {'wal_level': 'hot_standby', 'max_replication_slots': 5, 'f.oo': 'bar',
|
||||
'search_path': 'public', 'hot_standby': 'on', 'max_wal_senders': 5,
|
||||
'wal_keep_segments': 8, 'wal_log_hints': 'on', 'max_locks_per_transaction': 64,
|
||||
'max_worker_processes': 8, 'max_connections': 100, 'max_prepared_transactions': 0,
|
||||
'track_commit_timestamp': 'off'}
|
||||
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
@patch('psycopg2.connect', psycopg2_connect)
|
||||
@patch('os.rename', Mock())
|
||||
@patch.object(Postgresql, 'get_major_version', Mock(return_value=9.6))
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
def setUp(self):
|
||||
self.p = Postgresql({'name': 'test0', 'scope': 'batman', 'data_dir': 'data/test0',
|
||||
self.data_dir = 'data/test0'
|
||||
if not os.path.exists(self.data_dir):
|
||||
os.makedirs(self.data_dir)
|
||||
self.p = Postgresql({'name': 'test0', 'scope': 'batman', 'data_dir': self.data_dir, 'retry_timeout': 10,
|
||||
'listen': '127.0.0.1, *:5432', 'connect_address': '127.0.0.2:5432',
|
||||
'pg_hba': ['hostssl all all 0.0.0.0/0 md5', 'host all all 0.0.0.0/0 md5'],
|
||||
'superuser': {'password': 'test'},
|
||||
'admin': {'username': 'admin', 'password': 'admin'},
|
||||
'pg_rewind': {'username': 'admin', 'password': 'admin'},
|
||||
'replication': {'username': 'replicator',
|
||||
'password': 'rep-pass',
|
||||
'network': '127.0.0.1/32'},
|
||||
'parameters': {'foo': 'bar'}, 'recovery_conf': {'foo': 'bar'},
|
||||
'authentication': {'superuser': {'username': 'test', 'password': 'test'},
|
||||
'replication': {'username': 'replicator', 'password': 'rep-pass'}},
|
||||
'remove_data_directory_on_rewind_failure': True,
|
||||
'use_pg_rewind': True, 'pg_ctl_timeout': 'bla',
|
||||
'parameters': self._PARAMETERS,
|
||||
'recovery_conf': {'foo': 'bar'},
|
||||
'callbacks': {'on_start': 'true', 'on_stop': 'true',
|
||||
'on_restart': 'true', 'on_role_change': 'true',
|
||||
'on_reload': 'true'
|
||||
},
|
||||
'restore': 'true'})
|
||||
if not os.path.exists(self.p.data_dir):
|
||||
os.makedirs(self.p.data_dir)
|
||||
self.p._callback_executor = Mock()
|
||||
self.leadermem = Member(0, 'leader', 28, {'conn_url': 'postgres://replicator:[email protected]:5435/postgres'})
|
||||
self.leader = Leader(-1, 28, self.leadermem)
|
||||
self.other = Member(0, 'test1', 28, {'conn_url': 'postgres://replicator:[email protected]:5433/postgres'})
|
||||
self.other = Member(0, 'test-1', 28, {'conn_url': 'postgres://replicator:[email protected]:5433/postgres',
|
||||
'tags': {'replicatefrom': 'leader'}})
|
||||
self.me = Member(0, 'test0', 28, {'conn_url': 'postgres://replicator:[email protected]:5434/postgres'})
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree('data')
|
||||
|
||||
def test_data_directory_empty(self):
|
||||
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)
|
||||
self.assertEquals(self.p.get_initdb_options([{'encoding': 'UTF8'}, 'data-checksums']),
|
||||
['--encoding=UTF8', '--data-checksums'])
|
||||
self.assertRaises(Exception, self.p.get_initdb_options, [{'pgdata': 'bar'}])
|
||||
self.assertRaises(Exception, self.p.get_initdb_options, [{'foo': 'bar', 1: 2}])
|
||||
self.assertRaises(Exception, self.p.get_initdb_options, [1])
|
||||
|
||||
def test_initialize(self):
|
||||
self.assertTrue(self.p.initialize())
|
||||
self.assertTrue(os.path.exists(os.path.join(self.p.data_dir, 'pg_hba.conf')))
|
||||
@patch('os.path.exists', Mock(return_value=True))
|
||||
@patch('os.unlink', Mock())
|
||||
def test_delete_trigger_file(self):
|
||||
self.p.delete_trigger_file()
|
||||
|
||||
def test_start(self):
|
||||
self.assertTrue(self.p.start())
|
||||
self.p.is_running = false
|
||||
open(os.path.join(self.p.data_dir, 'postmaster.pid'), 'w').close()
|
||||
@patch('subprocess.Popen')
|
||||
@patch.object(Postgresql, 'wait_for_startup')
|
||||
@patch.object(Postgresql, 'wait_for_port_open')
|
||||
@patch.object(Postgresql, 'is_running')
|
||||
def test_start(self, mock_is_running, mock_wait_for_port_open, mock_wait_for_startup, mock_popen):
|
||||
mock_is_running.return_value = True
|
||||
mock_wait_for_port_open.return_value = True
|
||||
mock_wait_for_startup.return_value = False
|
||||
mock_popen.stdout.readline.return_value = '123'
|
||||
self.assertTrue(self.p.start())
|
||||
mock_is_running.return_value = False
|
||||
open(os.path.join(self.data_dir, 'postmaster.pid'), 'w').close()
|
||||
pg_conf = os.path.join(self.data_dir, 'postgresql.conf')
|
||||
open(pg_conf, 'w').close()
|
||||
self.assertFalse(self.p.start())
|
||||
with open(pg_conf) as f:
|
||||
lines = f.readlines()
|
||||
self.assertTrue("f.oo = 'bar'\n" in lines)
|
||||
|
||||
def test_stop(self):
|
||||
mock_wait_for_startup.return_value = None
|
||||
self.assertFalse(self.p.start(10))
|
||||
self.assertIsNone(self.p.start())
|
||||
|
||||
mock_wait_for_port_open.return_value = False
|
||||
self.assertFalse(self.p.start())
|
||||
|
||||
@patch.object(Postgresql, 'pg_isready')
|
||||
@patch.object(Postgresql, 'read_pid_file')
|
||||
@patch.object(Postgresql, 'is_pid_running')
|
||||
@patch('patroni.postgresql.polling_loop', Mock(return_value=range(1)))
|
||||
def test_wait_for_port_open(self, mock_is_pid_running, mock_read_pid_file, mock_pg_isready):
|
||||
mock_is_pid_running.return_value = False
|
||||
mock_pg_isready.return_value = STATE_NO_RESPONSE
|
||||
|
||||
# No pid file and postmaster death
|
||||
mock_read_pid_file.return_value = {}
|
||||
self.assertFalse(self.p.wait_for_port_open(42, 100., 1))
|
||||
|
||||
mock_is_pid_running.return_value = True
|
||||
|
||||
# timeout
|
||||
mock_read_pid_file.return_value = {'pid', 1}
|
||||
self.assertFalse(self.p.wait_for_port_open(42, 100., 1))
|
||||
|
||||
# Garbage pid
|
||||
mock_read_pid_file.return_value = {'pid': 'garbage', 'start_time': '101', 'data_dir': '',
|
||||
'socket_dir': '', 'port': '', 'listen_addr': ''}
|
||||
self.assertFalse(self.p.wait_for_port_open(42, 100., 1))
|
||||
|
||||
# Not ready
|
||||
mock_read_pid_file.return_value = {'pid': '42', 'start_time': '101', 'data_dir': '',
|
||||
'socket_dir': '', 'port': '', 'listen_addr': ''}
|
||||
self.assertFalse(self.p.wait_for_port_open(42, 100., 1))
|
||||
|
||||
# pg_isready failure
|
||||
mock_pg_isready.return_value = 'garbage'
|
||||
self.assertTrue(self.p.wait_for_port_open(42, 100., 1))
|
||||
|
||||
@patch.object(Postgresql, 'is_running')
|
||||
def test_stop(self, mock_is_running):
|
||||
mock_is_running.return_value = True
|
||||
self.assertTrue(self.p.stop())
|
||||
with patch('subprocess.call', Mock(return_value=1)):
|
||||
mock_is_running.return_value = False
|
||||
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.p.start = Mock(return_value=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'})
|
||||
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):
|
||||
self.assertTrue(self.p.sync_from_leader(self.leader))
|
||||
def test_checkpoint(self):
|
||||
with patch.object(MockCursor, 'fetchone', Mock(return_value=(True, ))):
|
||||
self.assertEquals(self.p.checkpoint({'user': 'postgres'}), 'is_in_recovery=true')
|
||||
with patch.object(MockCursor, 'execute', Mock(return_value=None)):
|
||||
self.assertIsNone(self.p.checkpoint())
|
||||
self.assertEquals(self.p.checkpoint(), 'not accessible or not healty')
|
||||
|
||||
@patch('subprocess.call', side_effect=Exception("Test"))
|
||||
@patch('subprocess.call', side_effect=OSError)
|
||||
@patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
|
||||
def test_pg_rewind(self, mock_call):
|
||||
self.assertTrue(self.p.rewind(self.leader))
|
||||
r = {'user': '', 'host': '', 'port': '', 'database': '', 'password': ''}
|
||||
self.assertTrue(self.p.rewind(r))
|
||||
subprocess.call = mock_call
|
||||
self.assertFalse(self.p.rewind(self.leader))
|
||||
self.assertFalse(self.p.rewind(r))
|
||||
|
||||
@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.demote()
|
||||
self.p.follow_the_leader(self.leader)
|
||||
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)
|
||||
@patch('os.unlink', Mock(return_value=True))
|
||||
@patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string))
|
||||
@patch.object(Postgresql, 'remove_data_directory', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'single_user_mode', Mock(return_value=1))
|
||||
@patch.object(Postgresql, 'write_pgpass', Mock(return_value={}))
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'can_rewind', PropertyMock(return_value=True))
|
||||
@patch.object(Postgresql, 'rewind', return_value=False)
|
||||
def test_follow(self, mock_pg_rewind):
|
||||
with patch.object(Postgresql, 'check_recovery_conf', Mock(return_value=True)):
|
||||
self.assertTrue(self.p.follow(None, None)) # nothing to do, recovery.conf has good primary_conninfo
|
||||
|
||||
self.p.follow(self.me, self.me) # follow is called when the node is holding leader lock
|
||||
|
||||
with patch.object(Postgresql, 'restart', Mock(return_value=False)):
|
||||
self.p.set_role('replica')
|
||||
self.p.follow(None, None) # restart without rewind
|
||||
|
||||
with patch.object(Postgresql, 'stop', Mock(return_value=False)):
|
||||
self.p.follow(self.leader, self.leader, need_rewind=True) # failed to stop postgres
|
||||
|
||||
self.p.follow(self.leader, self.leader) # "leader" is not accessible or is_in_recovery
|
||||
|
||||
with patch.object(Postgresql, 'checkpoint', Mock(return_value=None)):
|
||||
self.p.follow(self.leader, self.leader)
|
||||
mock_pg_rewind.return_value = True
|
||||
self.p.follow(self.leader, self.leader, need_rewind=True)
|
||||
|
||||
self.p.follow(None, None) # check_recovery_conf...
|
||||
|
||||
@patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string))
|
||||
def test_can_rewind(self):
|
||||
tmp = self.p.pg_rewind
|
||||
self.p.pg_rewind = None
|
||||
with patch('subprocess.call', MagicMock(return_value=1)):
|
||||
self.assertFalse(self.p.can_rewind)
|
||||
with patch('subprocess.call', side_effect=OSError):
|
||||
self.assertFalse(self.p.can_rewind)
|
||||
with patch.object(Postgresql, 'controldata', Mock(return_value={'wal_log_hints setting': 'on'})):
|
||||
self.assertTrue(self.p.can_rewind)
|
||||
self.p.config['use_pg_rewind'] = False
|
||||
self.assertFalse(self.p.can_rewind)
|
||||
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())
|
||||
@patch.object(Postgresql, 'remove_data_directory', Mock(return_value=True))
|
||||
def test_create_replica(self):
|
||||
self.p.delete_trigger_file = Mock(side_effect=OSError())
|
||||
self.assertEquals(self.p.create_replica({'host': '', 'port': '', 'user': ''}, ''), 1)
|
||||
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)
|
||||
|
||||
def test_create_connection_users(self):
|
||||
cfg = self.p.config
|
||||
cfg['superuser']['username'] = 'test'
|
||||
p = Postgresql(cfg)
|
||||
p.create_connection_users()
|
||||
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)
|
||||
|
||||
with patch('subprocess.call', Mock(return_value=1)):
|
||||
self.assertEquals(self.p.create_replica(self.leader), 1)
|
||||
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
def test_sync_replication_slots(self):
|
||||
self.p.start()
|
||||
cluster = Cluster(True, self.leader, 0, [self.me, self.other, self.leadermem], None)
|
||||
self.p.sync_replication_slots(cluster)
|
||||
self.p.query = Mock(side_effect=psycopg2.OperationalError)
|
||||
self.p.schedule_load_slots = True
|
||||
cluster = Cluster(True, None, self.leader, 0, [self.me, self.other, self.leadermem], None, None)
|
||||
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg2.OperationalError)):
|
||||
self.p.sync_replication_slots(cluster)
|
||||
self.p.sync_replication_slots(cluster)
|
||||
with mock.patch('patroni.postgresql.Postgresql.role', new_callable=PropertyMock(return_value='replica')):
|
||||
self.p.sync_replication_slots(cluster)
|
||||
with mock.patch('patroni.postgresql.logger.error', new_callable=Mock()) as errorlog_mock:
|
||||
self.p.query = Mock()
|
||||
alias1 = Member(0, 'test-3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
||||
alias2 = Member(0, 'test.3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
||||
cluster.members.extend([alias1, alias2])
|
||||
self.p.sync_replication_slots(cluster)
|
||||
errorlog_mock.assert_called_once()
|
||||
assert "test-3" in errorlog_mock.call_args[0][1]
|
||||
assert "test.3" in errorlog_mock.call_args[0][1]
|
||||
|
||||
@patch.object(MockConnect, 'closed', 2)
|
||||
def test__query(self):
|
||||
@@ -305,15 +390,20 @@ class TestPostgresql(unittest.TestCase):
|
||||
self.assertRaises(PostgresConnectionException, self.p.query, 'RetryFailedError')
|
||||
self.assertRaises(psycopg2.OperationalError, self.p.query, 'blabla')
|
||||
|
||||
@patch.object(Postgresql, 'pg_isready', Mock(return_value=STATE_REJECT))
|
||||
def test_is_leader(self):
|
||||
self.assertTrue(self.p.is_leader())
|
||||
with patch.object(Postgresql, '_query', Mock(side_effect=RetryFailedError(''))):
|
||||
self.assertRaises(PostgresConnectionException, self.p.is_leader)
|
||||
|
||||
def test_reload(self):
|
||||
self.assertTrue(self.p.reload())
|
||||
|
||||
def test_is_healthy(self):
|
||||
@patch.object(Postgresql, 'is_running')
|
||||
def test_is_healthy(self, mock_is_running):
|
||||
mock_is_running.return_value = True
|
||||
self.assertTrue(self.p.is_healthy())
|
||||
self.p.is_running = false
|
||||
mock_is_running.return_value = False
|
||||
self.assertFalse(self.p.is_healthy())
|
||||
|
||||
def test_promote(self):
|
||||
@@ -323,65 +413,100 @@ class TestPostgresql(unittest.TestCase):
|
||||
|
||||
def test_last_operation(self):
|
||||
self.assertEquals(self.p.last_operation(), '0')
|
||||
Thread(target=self.p.last_operation).start()
|
||||
|
||||
@patch('subprocess.Popen', Mock(side_effect=OSError()))
|
||||
@patch('os.path.isfile', Mock(return_value=True))
|
||||
@patch('os.kill', Mock(side_effect=Exception))
|
||||
@patch('os.getpid', Mock(return_value=2))
|
||||
@patch('os.getppid', Mock(return_value=2))
|
||||
@patch.object(builtins, 'open', mock_open(read_data='-1'))
|
||||
@patch.object(Postgresql, '_version_file_exists', Mock(return_value=True))
|
||||
def test_is_running(self):
|
||||
self.assertFalse(self.p.is_running())
|
||||
|
||||
@patch('shlex.split', Mock(side_effect=OSError))
|
||||
def test_call_nowait(self):
|
||||
self.assertFalse(self.p.call_nowait('on_start'))
|
||||
self.assertIsNone(self.p.call_nowait('on_start'))
|
||||
|
||||
def test_non_existing_callback(self):
|
||||
self.assertFalse(self.p.call_nowait('foobar'))
|
||||
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
def test_is_leader_exception(self):
|
||||
self.p.start()
|
||||
self.p.query = Mock(side_effect=psycopg2.OperationalError("not supported"))
|
||||
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())):
|
||||
with patch('os.rename', Mock(side_effect=OSError)):
|
||||
self.p.move_data_directory()
|
||||
|
||||
@patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
def test_bootstrap(self):
|
||||
with patch('subprocess.call', Mock(return_value=1)):
|
||||
self.assertRaises(PostgresException, self.p.bootstrap)
|
||||
self.p.bootstrap()
|
||||
self.p.bootstrap(self.leader)
|
||||
self.assertRaises(PostgresException, self.p.bootstrap, {})
|
||||
|
||||
with patch.object(Postgresql, 'run_bootstrap_post_init', Mock(return_value=False)):
|
||||
self.assertRaises(PostgresException, self.p.bootstrap, {})
|
||||
|
||||
self.p.bootstrap({'users': {'replicator': {'password': 'rep-pass', 'options': ['replication']}},
|
||||
'pg_hba': ['host replication replicator 127.0.0.1/32 md5',
|
||||
'hostssl all all 0.0.0.0/0 md5',
|
||||
'host all all 0.0.0.0/0 md5'],
|
||||
'post_init': '/bin/false'})
|
||||
with open(os.path.join(self.data_dir, 'pg_hba.conf')) as f:
|
||||
lines = f.readlines()
|
||||
assert 'host replication replicator 127.0.0.1/32 md5\n' in lines
|
||||
assert 'host all all 0.0.0.0/0 md5\n' in lines
|
||||
|
||||
def test_run_bootstrap_post_init(self):
|
||||
with patch('subprocess.call', Mock(return_value=1)):
|
||||
self.assertFalse(self.p.run_bootstrap_post_init({'post_init': '/bin/false'}))
|
||||
|
||||
with patch('subprocess.call', Mock(side_effect=OSError)):
|
||||
self.assertFalse(self.p.run_bootstrap_post_init({'post_init': '/bin/false'}))
|
||||
|
||||
with patch('subprocess.call', Mock(return_value=0)) as mock_method:
|
||||
self.p._superuser.pop('username')
|
||||
self.assertTrue(self.p.run_bootstrap_post_init({'post_init': '/bin/false'}))
|
||||
|
||||
mock_method.assert_called()
|
||||
args, kwargs = mock_method.call_args
|
||||
assert 'PGPASSFILE' in kwargs['env'].keys()
|
||||
self.assertEquals(args[0], ['/bin/false', 'postgres://localhost:5432/postgres'])
|
||||
|
||||
@patch('patroni.postgresql.Postgresql.create_replica', Mock(return_value=0))
|
||||
def test_clone(self):
|
||||
self.p.clone(self.leader)
|
||||
|
||||
@patch('os.listdir', Mock(return_value=['recovery.conf']))
|
||||
@patch('os.path.exists', Mock(return_value=True))
|
||||
def test_get_postgres_role_from_data_directory(self):
|
||||
self.assertEquals(self.p.get_postgres_role_from_data_directory(), 'replica')
|
||||
|
||||
def test_remove_data_directory(self):
|
||||
self.p.data_dir = 'data_dir'
|
||||
self.p.remove_data_directory()
|
||||
os.mkdir(self.p.data_dir)
|
||||
open(self.data_dir, 'w').close()
|
||||
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)):
|
||||
os.symlink('unexisting', self.data_dir)
|
||||
with patch('os.unlink', Mock(side_effect=OSError)):
|
||||
self.p.remove_data_directory()
|
||||
self.p.remove_data_directory()
|
||||
|
||||
@patch('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)
|
||||
@patch('patroni.postgresql.Postgresql._version_file_exists', Mock(return_value=True))
|
||||
def test_controldata(self):
|
||||
with patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string)):
|
||||
data = self.p.controldata()
|
||||
self.assertEquals(len(data), 50)
|
||||
self.assertEquals(data['Database cluster state'], 'shut down in recovery')
|
||||
self.assertEquals(data['wal_log_hints setting'], 'on')
|
||||
self.assertEquals(int(data['Database block size']), 8192)
|
||||
|
||||
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())
|
||||
with patch('subprocess.check_output', Mock(side_effect=subprocess.CalledProcessError(1, ''))):
|
||||
self.assertEquals(self.p.controldata(), {})
|
||||
|
||||
def test_read_postmaster_opts(self):
|
||||
m = mock_open(read_data=postmaster_opts_string())
|
||||
@@ -391,47 +516,38 @@ class TestPostgresql(unittest.TestCase):
|
||||
self.assertEquals(int(data['max_replication_slots']), 5)
|
||||
self.assertEqual(data.get('D'), None)
|
||||
|
||||
m.side_effect = IOError("foo")
|
||||
m.side_effect = IOError
|
||||
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,
|
||||
subprocess_popen_mock.assert_called_once_with(['postgres', '--single', '-D', self.data_dir,
|
||||
'-c', 'archive_command=false', '-c', 'archive_mode=on',
|
||||
'postgres'], stdin=subprocess.PIPE,
|
||||
stdout=42,
|
||||
stderr=subprocess.STDOUT)
|
||||
subprocess_popen_mock.reset_mock()
|
||||
self.assertEquals(self.p.single_user_mode(command="CHECKPOINT"), 0)
|
||||
subprocess_popen_mock.assert_called_once_with(['postgres', '--single', '-D', self.p.data_dir,
|
||||
subprocess_popen_mock.assert_called_once_with(['postgres', '--single', '-D', self.data_dir,
|
||||
'postgres'], stdin=subprocess.PIPE,
|
||||
stdout=42,
|
||||
stderr=subprocess.STDOUT)
|
||||
subprocess_popen_mock.return_value = None
|
||||
self.assertEquals(self.p.single_user_mode(), 1)
|
||||
|
||||
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/')
|
||||
ap = os.path.join(self.data_dir, 'pg_xlog', 'archive_status/')
|
||||
self.p.cleanup_archive_status()
|
||||
mock_remove.assert_has_calls([mock.call(ap+'a'), mock.call(ap+'b'), mock.call(ap+'c')])
|
||||
mock_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()
|
||||
@@ -439,30 +555,218 @@ class TestPostgresql(unittest.TestCase):
|
||||
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_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")
|
||||
mock_file.side_effect = OSError
|
||||
mock_link.side_effect = OSError
|
||||
self.p.cleanup_archive_status()
|
||||
mock_unlink.assert_not_called()
|
||||
mock_remove.assert_not_called()
|
||||
|
||||
@patch('patroni.postgresql.Postgresql._version_file_exists', Mock(return_value=True))
|
||||
@patch('subprocess.check_output', MagicMock(return_value=0, side_effect=pg_controldata_string))
|
||||
def test_sysid(self):
|
||||
self.assertEqual(self.p.sysid, "6200971513092291716")
|
||||
|
||||
@patch('os.path.isfile', MagicMock(return_value=True))
|
||||
@patch('shutil.copy', side_effect=Exception)
|
||||
def test_save_configuration_files(self, mock_copy):
|
||||
shutil.copy = mock_copy
|
||||
@patch('os.path.isfile', Mock(return_value=True))
|
||||
@patch('shutil.copy', Mock(side_effect=IOError))
|
||||
def test_save_configuration_files(self):
|
||||
self.p.save_configuration_files()
|
||||
|
||||
@patch('os.path.isfile', 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
|
||||
@patch('os.path.isfile', Mock(side_effect=[False, True]))
|
||||
@patch('shutil.copy', Mock(side_effect=IOError))
|
||||
def test_restore_configuration_files(self):
|
||||
self.p.restore_configuration_files()
|
||||
|
||||
def test_can_create_replica_without_replication_connection(self):
|
||||
self.p.config['create_replica_method'] = []
|
||||
self.assertFalse(self.p.can_create_replica_without_replication_connection())
|
||||
self.p.config['create_replica_method'] = ['wale', 'basebackup']
|
||||
self.p.config['wale'] = {'command': 'foo', 'no_master': 1}
|
||||
self.assertTrue(self.p.can_create_replica_without_replication_connection())
|
||||
|
||||
def test_replica_method_can_work_without_replication_connection(self):
|
||||
self.assertFalse(self.p.replica_method_can_work_without_replication_connection('basebackup'))
|
||||
self.assertFalse(self.p.replica_method_can_work_without_replication_connection('foobar'))
|
||||
self.p.config['foo'] = {'command': 'bar', 'no_master': 1}
|
||||
self.assertTrue(self.p.replica_method_can_work_without_replication_connection('foo'))
|
||||
self.p.config['foo'] = {'command': 'bar'}
|
||||
self.assertFalse(self.p.replica_method_can_work_without_replication_connection('foo'))
|
||||
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
def test_reload_config(self):
|
||||
parameters = self._PARAMETERS.copy()
|
||||
parameters.pop('f.oo')
|
||||
self.p.reload_config({'retry_timeout': 10, 'listen': '*', 'parameters': parameters})
|
||||
parameters['b.ar'] = 'bar'
|
||||
self.p.reload_config({'retry_timeout': 10, 'listen': '*', 'parameters': parameters})
|
||||
parameters['autovacuum'] = 'on'
|
||||
self.p.reload_config({'retry_timeout': 10, 'listen': '*', 'parameters': parameters})
|
||||
parameters['autovacuum'] = 'off'
|
||||
parameters.pop('search_path')
|
||||
self.p.reload_config({'retry_timeout': 10, 'listen': '*:5433', 'parameters': parameters})
|
||||
|
||||
@patch.object(Postgresql, '_version_file_exists', Mock(return_value=True))
|
||||
def test_get_major_version(self):
|
||||
with patch.object(builtins, 'open', mock_open(read_data='9.4')):
|
||||
self.assertEquals(self.p.get_major_version(), 9.4)
|
||||
with patch.object(builtins, 'open', Mock(side_effect=Exception)):
|
||||
self.assertEquals(self.p.get_major_version(), 0.0)
|
||||
|
||||
def test_postmaster_start_time(self):
|
||||
with patch.object(MockCursor, "fetchone", Mock(return_value=('foo', True, '', '', '', '', False))):
|
||||
self.assertEqual(self.p.postmaster_start_time(), 'foo')
|
||||
with patch.object(MockCursor, "execute", side_effect=psycopg2.Error):
|
||||
self.assertIsNone(self.p.postmaster_start_time())
|
||||
|
||||
def test_check_for_startup(self):
|
||||
with patch('subprocess.call', return_value=0):
|
||||
self.p._state = 'starting'
|
||||
self.assertFalse(self.p.check_for_startup())
|
||||
self.assertEquals(self.p.state, 'running')
|
||||
|
||||
with patch('subprocess.call', return_value=1):
|
||||
self.p._state = 'starting'
|
||||
self.assertTrue(self.p.check_for_startup())
|
||||
self.assertEquals(self.p.state, 'starting')
|
||||
|
||||
with patch('subprocess.call', return_value=2):
|
||||
self.p._state = 'starting'
|
||||
self.assertFalse(self.p.check_for_startup())
|
||||
self.assertEquals(self.p.state, 'start failed')
|
||||
|
||||
with patch('subprocess.call', return_value=0):
|
||||
self.p._state = 'running'
|
||||
self.assertFalse(self.p.check_for_startup())
|
||||
self.assertEquals(self.p.state, 'running')
|
||||
|
||||
with patch('subprocess.call', return_value=127):
|
||||
self.p._state = 'running'
|
||||
self.assertFalse(self.p.check_for_startup())
|
||||
self.assertEquals(self.p.state, 'running')
|
||||
|
||||
self.p._state = 'starting'
|
||||
self.assertFalse(self.p.check_for_startup())
|
||||
self.assertEquals(self.p.state, 'running')
|
||||
|
||||
def test_wait_for_startup(self):
|
||||
state = {'sleeps': 0, 'num_rejects': 0, 'final_return': 0}
|
||||
|
||||
def increment_sleeps(*args):
|
||||
print("Sleep")
|
||||
state['sleeps'] += 1
|
||||
|
||||
def isready_return(*args):
|
||||
ret = 1 if state['sleeps'] < state['num_rejects'] else state['final_return']
|
||||
print("Isready {0} {1}".format(ret, state))
|
||||
return ret
|
||||
|
||||
def time_in_state(*args):
|
||||
return state['sleeps']
|
||||
|
||||
with patch('subprocess.call', side_effect=isready_return):
|
||||
with patch('time.sleep', side_effect=increment_sleeps):
|
||||
self.p.time_in_state = Mock(side_effect=time_in_state)
|
||||
|
||||
self.p._state = 'stopped'
|
||||
self.assertTrue(self.p.wait_for_startup())
|
||||
self.assertEquals(state['sleeps'], 0)
|
||||
|
||||
self.p._state = 'starting'
|
||||
state['num_rejects'] = 5
|
||||
self.assertTrue(self.p.wait_for_startup())
|
||||
self.assertEquals(state['sleeps'], 5)
|
||||
|
||||
self.p._state = 'starting'
|
||||
state['sleeps'] = 0
|
||||
state['final_return'] = 2
|
||||
self.assertFalse(self.p.wait_for_startup())
|
||||
|
||||
self.p._state = 'starting'
|
||||
state['sleeps'] = 0
|
||||
state['final_return'] = 0
|
||||
self.assertFalse(self.p.wait_for_startup(timeout=2))
|
||||
self.assertEquals(state['sleeps'], 3)
|
||||
|
||||
def test_read_pid_file(self):
|
||||
pidfile = os.path.join(self.data_dir, 'postmaster.pid')
|
||||
if os.path.exists(pidfile):
|
||||
os.remove(pidfile)
|
||||
self.assertEquals(self.p.read_pid_file(), {})
|
||||
|
||||
@patch('os.kill')
|
||||
def test_is_pid_running(self, mock_kill):
|
||||
mock_kill.return_value = True
|
||||
self.assertTrue(self.p.is_pid_running(-100))
|
||||
self.assertFalse(self.p.is_pid_running(0))
|
||||
self.assertFalse(self.p.is_pid_running(None))
|
||||
|
||||
def test_pick_sync_standby(self):
|
||||
cluster = Cluster(True, None, self.leader, 0, [self.me, self.other, self.leadermem], None,
|
||||
SyncState(0, self.me.name, self.leadermem.name))
|
||||
|
||||
with patch.object(Postgresql, "query", return_value=[
|
||||
(self.leadermem.name, 'streaming', 'sync'),
|
||||
(self.me.name, 'streaming', 'async'),
|
||||
(self.other.name, 'streaming', 'async'),
|
||||
]):
|
||||
self.assertEquals(self.p.pick_synchronous_standby(cluster), (self.leadermem.name, True))
|
||||
|
||||
with patch.object(Postgresql, "query", return_value=[
|
||||
(self.me.name, 'streaming', 'async'),
|
||||
(self.leadermem.name, 'streaming', 'potential'),
|
||||
(self.other.name, 'streaming', 'async'),
|
||||
]):
|
||||
self.assertEquals(self.p.pick_synchronous_standby(cluster), (self.leadermem.name, False))
|
||||
|
||||
with patch.object(Postgresql, "query", return_value=[
|
||||
(self.me.name, 'streaming', 'async'),
|
||||
(self.other.name, 'streaming', 'async'),
|
||||
]):
|
||||
self.assertEquals(self.p.pick_synchronous_standby(cluster), (self.me.name, False))
|
||||
|
||||
with patch.object(Postgresql, "query", return_value=[
|
||||
('missing', 'streaming', 'sync'),
|
||||
(self.me.name, 'streaming', 'async'),
|
||||
(self.other.name, 'streaming', 'async'),
|
||||
]):
|
||||
self.assertEquals(self.p.pick_synchronous_standby(cluster), (self.me.name, False))
|
||||
|
||||
with patch.object(Postgresql, "query", return_value=[]):
|
||||
self.assertEquals(self.p.pick_synchronous_standby(cluster), (None, False))
|
||||
|
||||
def test_set_sync_standby(self):
|
||||
def value_in_conf():
|
||||
with open(os.path.join(self.data_dir, 'postgresql.conf')) as f:
|
||||
for line in f:
|
||||
if line.startswith('synchronous_standby_names'):
|
||||
return line.strip()
|
||||
|
||||
mock_reload = self.p.reload = Mock()
|
||||
self.p.set_synchronous_standby('n1')
|
||||
self.assertEquals(value_in_conf(), "synchronous_standby_names = 'n1'")
|
||||
mock_reload.assert_called()
|
||||
|
||||
mock_reload.reset_mock()
|
||||
self.p.set_synchronous_standby('n1')
|
||||
mock_reload.assert_not_called()
|
||||
self.assertEquals(value_in_conf(), "synchronous_standby_names = 'n1'")
|
||||
|
||||
self.p.set_synchronous_standby('n2')
|
||||
mock_reload.assert_called()
|
||||
self.assertEquals(value_in_conf(), "synchronous_standby_names = 'n2'")
|
||||
|
||||
mock_reload.reset_mock()
|
||||
self.p.set_synchronous_standby(None)
|
||||
mock_reload.assert_called()
|
||||
self.assertEquals(value_in_conf(), None)
|
||||
|
||||
def test_get_server_parameters(self):
|
||||
config = {'synchronous_mode': True, 'parameters': {}, 'listen': '0'}
|
||||
self.p.get_server_parameters(config)
|
||||
self.p.set_synchronous_standby('foo')
|
||||
self.p.get_server_parameters(config)
|
||||
|
||||
@@ -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)
|
||||
+11
-28
@@ -2,34 +2,20 @@ import unittest
|
||||
|
||||
from mock import Mock, patch
|
||||
from patroni.exceptions import PatroniException
|
||||
from patroni.utils import Retry, RetryFailedError, reap_children, sigchld_handler, sigterm_handler, sleep
|
||||
|
||||
|
||||
def time_sleep(_):
|
||||
sigchld_handler(None, None)
|
||||
from patroni.utils import Retry, RetryFailedError, polling_loop
|
||||
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
|
||||
def test_sigterm_handler(self):
|
||||
self.assertRaises(SystemExit, sigterm_handler, None, None)
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
def test_reap_children(self):
|
||||
reap_children()
|
||||
with patch('os.waitpid', Mock(return_value=(0, 0))):
|
||||
sigchld_handler(None, None)
|
||||
reap_children()
|
||||
|
||||
@patch('time.sleep', time_sleep)
|
||||
def test_sleep(self):
|
||||
sleep(0.01)
|
||||
def test_polling_loop(self):
|
||||
self.assertEquals(list(polling_loop(0.001, interval=0.001)), [0])
|
||||
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
class TestRetrySleeper(unittest.TestCase):
|
||||
|
||||
def _fail(self, times=1):
|
||||
@staticmethod
|
||||
def _fail(times=1):
|
||||
scope = dict(times=0)
|
||||
|
||||
def inner():
|
||||
@@ -40,36 +26,33 @@ class TestRetrySleeper(unittest.TestCase):
|
||||
raise PatroniException('Failed!')
|
||||
return inner
|
||||
|
||||
def _makeOne(self, *args, **kwargs):
|
||||
return Retry(*args, **kwargs)
|
||||
|
||||
def test_reset(self):
|
||||
retry = self._makeOne(delay=0, max_tries=2)
|
||||
retry = Retry(delay=0, max_tries=2)
|
||||
retry(self._fail())
|
||||
self.assertEquals(retry._attempts, 1)
|
||||
retry.reset()
|
||||
self.assertEquals(retry._attempts, 0)
|
||||
|
||||
def test_too_many_tries(self):
|
||||
retry = self._makeOne(delay=0)
|
||||
retry = Retry(delay=0)
|
||||
self.assertRaises(RetryFailedError, retry, self._fail(times=999))
|
||||
self.assertEquals(retry._attempts, 1)
|
||||
|
||||
def test_maximum_delay(self):
|
||||
retry = self._makeOne(delay=10, max_tries=100)
|
||||
retry = Retry(delay=10, max_tries=100)
|
||||
retry(self._fail(times=10))
|
||||
self.assertTrue(retry._cur_delay < 4000, retry._cur_delay)
|
||||
# gevent's sleep function is picky about the type
|
||||
self.assertEquals(type(retry._cur_delay), float)
|
||||
|
||||
def test_deadline(self):
|
||||
retry = self._makeOne(deadline=0.0001)
|
||||
retry = Retry(deadline=0.0001)
|
||||
self.assertRaises(RetryFailedError, retry, self._fail(times=100))
|
||||
|
||||
def test_copy(self):
|
||||
def _sleep(t):
|
||||
None
|
||||
pass
|
||||
|
||||
retry = self._makeOne(sleep_func=_sleep)
|
||||
retry = Retry(sleep_func=_sleep)
|
||||
rcopy = retry.copy()
|
||||
self.assertTrue(rcopy.sleep_func is _sleep)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import psycopg2
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from mock import Mock, MagicMock, patch, mock_open
|
||||
from patroni.scripts.wale_restore import WALERestore, main as _main, get_major_version
|
||||
from six.moves import builtins
|
||||
|
||||
|
||||
wale_output = b'name last_modified expanded_size_bytes wal_segment_backup_start ' +\
|
||||
b'wal_segment_offset_backup_start wal_segment_backup_stop wal_segment_offset_backup_stop\n' +\
|
||||
b'base_00000001000000000000007F_00000040 2015-05-18T10:13:25.000Z 167772160 ' +\
|
||||
b'00000001000000000000007F 00000040 00000001000000000000007F 00000240\n'
|
||||
|
||||
|
||||
@patch('os.access', Mock(return_value=True))
|
||||
@patch('os.makedirs', Mock(return_value=True))
|
||||
@patch('os.path.exists', Mock(return_value=True))
|
||||
@patch('os.path.isdir', Mock(return_value=True))
|
||||
@patch('psycopg2.extensions.cursor', Mock(autospec=True))
|
||||
@patch('psycopg2.extensions.connection', Mock(autospec=True))
|
||||
@patch('psycopg2.connect', MagicMock(autospec=True))
|
||||
@patch('subprocess.check_output', Mock(return_value=wale_output))
|
||||
class TestWALERestore(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.wale_restore = WALERestore("batman", "/data", "host=batman port=5432 user=batman",
|
||||
"/etc", 100, 100, 1, 0, 1)
|
||||
|
||||
def test_should_use_s3_to_create_replica(self):
|
||||
self.assertTrue(self.wale_restore.should_use_s3_to_create_replica())
|
||||
|
||||
with patch('psycopg2.connect', Mock(side_effect=psycopg2.Error("foo"))):
|
||||
save_no_master = self.wale_restore.no_master
|
||||
save_master_connection = self.wale_restore.master_connection
|
||||
|
||||
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
|
||||
self.wale_restore.no_master = 1
|
||||
self.assertTrue(self.wale_restore.should_use_s3_to_create_replica()) # this would do 2 retries 1 sec each
|
||||
self.wale_restore.master_connection = ''
|
||||
self.assertTrue(self.wale_restore.should_use_s3_to_create_replica())
|
||||
|
||||
self.wale_restore.no_master = save_no_master
|
||||
self.wale_restore.master_connection = save_master_connection
|
||||
|
||||
with patch('subprocess.check_output', Mock(side_effect=subprocess.CalledProcessError(1, "cmd", "foo"))):
|
||||
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
|
||||
with patch('subprocess.check_output', Mock(return_value=wale_output.split(b'\n')[0])):
|
||||
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
|
||||
with patch('subprocess.check_output',
|
||||
Mock(return_value=wale_output.replace(b' wal_segment_offset_backup_stop', b''))):
|
||||
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
|
||||
with patch('subprocess.check_output',
|
||||
Mock(return_value=wale_output.replace(b'expanded_size_bytes', b'expanded_size_foo'))):
|
||||
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
|
||||
|
||||
def test_create_replica_with_s3(self):
|
||||
with patch('subprocess.call', Mock(return_value=0)):
|
||||
self.assertEqual(self.wale_restore.create_replica_with_s3(), 0)
|
||||
with patch.object(self.wale_restore, 'fix_subdirectory_path_if_broken', Mock(return_value=False)):
|
||||
self.assertEqual(self.wale_restore.create_replica_with_s3(), 2)
|
||||
|
||||
with patch('subprocess.call', Mock(side_effect=Exception("foo"))):
|
||||
self.assertEqual(self.wale_restore.create_replica_with_s3(), 1)
|
||||
|
||||
def test_run(self):
|
||||
self.wale_restore.init_error = True
|
||||
self.assertEqual(self.wale_restore.run(), 2) # this would do 2 retries 1 sec each
|
||||
self.wale_restore.init_error = False
|
||||
with patch.object(self.wale_restore, 'should_use_s3_to_create_replica', Mock(return_value=True)):
|
||||
with patch.object(self.wale_restore, 'create_replica_with_s3', Mock(return_value=0)):
|
||||
self.assertEqual(self.wale_restore.run(), 0)
|
||||
with patch.object(self.wale_restore, 'should_use_s3_to_create_replica', Mock(return_value=None)):
|
||||
self.assertEqual(self.wale_restore.run(), 1)
|
||||
with patch.object(self.wale_restore, 'should_use_s3_to_create_replica', Mock(side_effect=Exception)):
|
||||
self.assertEqual(self.wale_restore.run(), 2)
|
||||
|
||||
@patch('sys.exit', Mock())
|
||||
def test_main(self):
|
||||
with patch.object(WALERestore, 'run', Mock(return_value=0)):
|
||||
self.assertEqual(_main(), 0)
|
||||
with patch.object(WALERestore, 'run', Mock(return_value=1)):
|
||||
self.assertEqual(_main(), 1)
|
||||
|
||||
@patch('os.path.isfile', Mock(return_value=True))
|
||||
def test_get_major_version(self):
|
||||
with patch.object(builtins, 'open', mock_open(read_data='9.4')):
|
||||
self.assertEqual(get_major_version("data"), 9.4)
|
||||
with patch.object(builtins, 'open', side_effect=OSError):
|
||||
self.assertEqual(get_major_version("data"), 0.0)
|
||||
|
||||
@patch('os.path.islink', Mock(return_value=True))
|
||||
@patch('os.readlink', Mock(return_value="foo"))
|
||||
@patch('os.remove', Mock())
|
||||
@patch('os.mkdir', Mock())
|
||||
def test_fix_subdirectory_path_if_broken(self):
|
||||
with patch('os.path.exists', Mock(return_value=False)): # overriding the class-wide mock
|
||||
self.assertTrue(self.wale_restore.fix_subdirectory_path_if_broken("data1"))
|
||||
for fn in ('os.remove', 'os.mkdir'):
|
||||
with patch(fn, side_effect=OSError):
|
||||
self.assertFalse(self.wale_restore.fix_subdirectory_path_if_broken("data3"))
|
||||
+78
-35
@@ -1,26 +1,28 @@
|
||||
import six
|
||||
import unittest
|
||||
|
||||
from mock import Mock, patch
|
||||
from patroni.dcs import Leader
|
||||
from patroni.zookeeper import ExhibitorEnsembleProvider, ZooKeeper, ZooKeeperError
|
||||
from kazoo.client import KazooState
|
||||
from kazoo.exceptions import NoNodeError, NodeExistsError
|
||||
from kazoo.handlers.threading import SequentialThreadingHandler
|
||||
from kazoo.protocol.states import ZnodeStat
|
||||
from test_etcd import SleepException, requests_get
|
||||
from mock import Mock, patch
|
||||
from patroni.dcs.zookeeper import Leader, PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError
|
||||
|
||||
|
||||
class MockKazooClient(Mock):
|
||||
|
||||
leader = False
|
||||
exists = True
|
||||
handler = Mock()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MockKazooClient, self).__init__()
|
||||
|
||||
@property
|
||||
def client_id(self):
|
||||
return (-1, '')
|
||||
|
||||
def retry(self, func, *args, **kwargs):
|
||||
@staticmethod
|
||||
def retry(func, *args, **kwargs):
|
||||
func(*args, **kwargs)
|
||||
|
||||
def get(self, path, watch=None):
|
||||
@@ -33,8 +35,6 @@ class MockKazooClient(Mock):
|
||||
b'postgres://repuser:rep-pass@localhost:5434/postgres?application_name=http://127.0.0.1:8009/patroni',
|
||||
ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0 if self.exists else -1, 0, 0, 0)
|
||||
)
|
||||
elif path.endswith('/optime/leader'):
|
||||
return (b'1', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||||
elif path.endswith('/leader'):
|
||||
if self.leader:
|
||||
return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0))
|
||||
@@ -43,13 +43,14 @@ class MockKazooClient(Mock):
|
||||
return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||||
return (b'', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||||
|
||||
def get_children(self, path, watch=None, include_data=False):
|
||||
@staticmethod
|
||||
def get_children(path, watch=None, include_data=False):
|
||||
if not isinstance(path, six.string_types):
|
||||
raise TypeError("Invalid type for 'path' (string expected)")
|
||||
if path.startswith('/no_node'):
|
||||
raise NoNodeError
|
||||
elif path in ['/service/bla/', '/service/test/']:
|
||||
return ['initialize', 'leader', 'members', 'optime', 'failover']
|
||||
return ['initialize', 'leader', 'members', 'optime', 'failover', 'sync']
|
||||
return ['foo', 'bar', 'buzz']
|
||||
|
||||
def create(self, path, value=b"", acl=None, ephemeral=False, sequence=False, makepath=False):
|
||||
@@ -57,61 +58,77 @@ class MockKazooClient(Mock):
|
||||
raise TypeError("Invalid type for 'path' (string expected)")
|
||||
if not isinstance(value, (six.binary_type,)):
|
||||
raise TypeError("Invalid type for 'value' (must be a byte string)")
|
||||
if value == b'Exception':
|
||||
raise Exception
|
||||
if path.endswith('/initialize') or path == '/service/test/optime/leader':
|
||||
raise Exception
|
||||
elif value == b'retry' or (value == b'exists' and self.exists):
|
||||
raise NodeExistsError
|
||||
|
||||
def set(self, path, value, version=-1):
|
||||
def create_async(self, path, value=b"", acl=None, ephemeral=False, sequence=False, makepath=False):
|
||||
return self.create(path, value, acl, ephemeral, sequence, makepath) or Mock()
|
||||
|
||||
@staticmethod
|
||||
def set(path, value, version=-1):
|
||||
if not isinstance(path, six.string_types):
|
||||
raise TypeError("Invalid type for 'path' (string expected)")
|
||||
if not isinstance(value, (six.binary_type,)):
|
||||
raise TypeError("Invalid type for 'value' (must be a byte string)")
|
||||
if path == '/service/bla/optime/leader':
|
||||
raise Exception
|
||||
if path == '/service/test/members/bar':
|
||||
if value == b'retry':
|
||||
return
|
||||
if path == '/service/test/failover':
|
||||
if path == '/service/test/members/bar' and value == b'retry':
|
||||
return
|
||||
if path in ('/service/test/failover', '/service/test/config', '/service/test/sync'):
|
||||
if value == b'Exception':
|
||||
raise Exception
|
||||
elif value == b'ok':
|
||||
return
|
||||
raise NoNodeError
|
||||
|
||||
def set_async(self, path, value, version=-1):
|
||||
return self.set(path, value, version) or Mock()
|
||||
|
||||
def delete(self, path, version=-1, recursive=False):
|
||||
if not isinstance(path, six.string_types):
|
||||
raise TypeError("Invalid type for 'path' (string expected)")
|
||||
self.exists = False
|
||||
if path == '/service/test/leader':
|
||||
if self.leader:
|
||||
return
|
||||
self.leader = True
|
||||
raise Exception
|
||||
elif path == '/service/test/members/buzz':
|
||||
raise Exception
|
||||
elif path.endswith('/initialize') or path == '/service/test/members/bar':
|
||||
elif path.endswith('/') or path.endswith('/initialize') or path == '/service/test/members/bar':
|
||||
raise NoNodeError
|
||||
|
||||
def delete_async(self, path, version=-1, recursive=False):
|
||||
return self.delete(path, version, recursive) or Mock()
|
||||
|
||||
@patch('requests.get', requests_get)
|
||||
@patch('patroni.zookeeper.sleep', Mock(side_effect=SleepException()))
|
||||
class TestExhibitorEnsembleProvider(unittest.TestCase):
|
||||
|
||||
def test_init(self):
|
||||
self.assertRaises(SleepException, ExhibitorEnsembleProvider, ['localhost'], 8181)
|
||||
class TestPatroniSequentialThreadingHandler(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.handler = PatroniSequentialThreadingHandler(10)
|
||||
|
||||
@patch.object(SequentialThreadingHandler, 'create_connection', Mock())
|
||||
def test_create_connection(self):
|
||||
self.assertIsNotNone(self.handler.create_connection(()))
|
||||
self.assertIsNotNone(self.handler.create_connection((), 40))
|
||||
|
||||
|
||||
class TestZooKeeper(unittest.TestCase):
|
||||
|
||||
@patch('requests.get', requests_get)
|
||||
@patch('patroni.zookeeper.KazooClient', MockKazooClient)
|
||||
@patch('patroni.dcs.zookeeper.KazooClient', MockKazooClient)
|
||||
def setUp(self):
|
||||
self.zk = ZooKeeper('foo', {'exhibitor': {'hosts': ['localhost', 'exhibitor'], 'port': 8181}, 'scope': 'test'})
|
||||
self.zk = ZooKeeper({'hosts': ['localhost:2181'], 'scope': 'test',
|
||||
'name': 'foo', 'ttl': 30, 'retry_timeout': 10, 'loop_wait': 10})
|
||||
|
||||
def test_session_listener(self):
|
||||
self.zk.session_listener(KazooState.SUSPENDED)
|
||||
|
||||
def test_reload_config(self):
|
||||
self.zk.reload_config({'ttl': 20, 'retry_timeout': 10, 'loop_wait': 10})
|
||||
self.zk.reload_config({'ttl': 20, 'retry_timeout': 10, 'loop_wait': 5})
|
||||
|
||||
def test_get_node(self):
|
||||
self.assertIsNone(self.zk.get_node('/no_node'))
|
||||
|
||||
@@ -126,17 +143,23 @@ class TestZooKeeper(unittest.TestCase):
|
||||
|
||||
def test_get_cluster(self):
|
||||
self.assertRaises(ZooKeeperError, self.zk.get_cluster)
|
||||
self.zk.exhibitor.poll = lambda: True
|
||||
cluster = self.zk.get_cluster()
|
||||
self.assertIsInstance(cluster.leader, Leader)
|
||||
self.zk.touch_member('foo')
|
||||
self.zk.delete_leader()
|
||||
|
||||
def test_delete_leader(self):
|
||||
self.assertTrue(self.zk.delete_leader())
|
||||
|
||||
def test_set_failover_value(self):
|
||||
self.zk.set_failover_value('')
|
||||
self.zk.set_failover_value('ok')
|
||||
self.zk.set_failover_value('Exception')
|
||||
|
||||
def test_set_config_value(self):
|
||||
self.zk.set_config_value('')
|
||||
self.zk.set_config_value('ok')
|
||||
self.zk.set_config_value('Exception')
|
||||
|
||||
def test_initialize(self):
|
||||
self.assertFalse(self.zk.initialize())
|
||||
|
||||
@@ -150,16 +173,18 @@ class TestZooKeeper(unittest.TestCase):
|
||||
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._client.exists = 1
|
||||
self.zk.touch_member('Exception')
|
||||
self.zk._name = 'bar'
|
||||
self.zk.touch_member('retry')
|
||||
self.zk.fetch_cluster = True
|
||||
self.zk._fetch_cluster = True
|
||||
self.zk.get_cluster()
|
||||
self.zk.touch_member('retry')
|
||||
|
||||
def test_take_leader(self):
|
||||
self.zk.take_leader()
|
||||
with patch.object(MockKazooClient, 'create', Mock(side_effect=Exception)):
|
||||
self.zk.take_leader()
|
||||
|
||||
def test_update_leader(self):
|
||||
self.assertTrue(self.zk.update_leader())
|
||||
@@ -167,10 +192,28 @@ class TestZooKeeper(unittest.TestCase):
|
||||
def test_write_leader_optime(self):
|
||||
self.zk.last_leader_operation = '0'
|
||||
self.zk.write_leader_optime('1')
|
||||
with patch.object(MockKazooClient, 'create_async', Mock()):
|
||||
self.zk.write_leader_optime('1')
|
||||
with patch.object(MockKazooClient, 'set_async', Mock()):
|
||||
self.zk.write_leader_optime('2')
|
||||
self.zk._base_path = self.zk._base_path.replace('test', 'bla')
|
||||
self.zk.write_leader_optime('2')
|
||||
self.zk.write_leader_optime('3')
|
||||
|
||||
def test_delete_cluster(self):
|
||||
self.assertTrue(self.zk.delete_cluster())
|
||||
|
||||
def test_watch(self):
|
||||
self.zk.watch(0)
|
||||
self.zk.event.isSet = lambda: True
|
||||
self.zk.watch(0)
|
||||
self.zk.watch(None, 0)
|
||||
self.zk.event.isSet = Mock(return_value=True)
|
||||
self.zk.watch(None, 0)
|
||||
|
||||
def test__kazoo_connect(self):
|
||||
self.zk._client._retry.deadline = 1
|
||||
self.zk._orig_kazoo_connect = Mock(return_value=(0, 0))
|
||||
self.zk._kazoo_connect(None, None)
|
||||
|
||||
def test_sync_state(self):
|
||||
self.zk.set_sync_state_value('')
|
||||
self.zk.set_sync_state_value('ok')
|
||||
self.zk.set_sync_state_value('Exception')
|
||||
self.zk.delete_sync_state()
|
||||
|
||||
Reference in New Issue
Block a user