Compare commits

..
Author SHA1 Message Date
Alexander Kukushkin b3f109751c Merge branch 'master' of github.com:zalando/patroni into feature/terminaltables 2020-06-30 16:56:47 +02:00
Alexander Kukushkin 52761ac46c Merge branch 'master' of github.com:zalando/patroni into feature/terminaltables 2020-04-15 12:29:12 +02:00
Alexander Kukushkin 7c409f59d7 Switch to texttable
it seems to be well maintained and packages are available even for old distros.
2020-02-19 12:29:25 +01:00
Alexander Kukushkin ee79a390c2 Fix little bug and unit-tests 2020-02-14 14:30:48 +01:00
Alexander Kukushkin 6dcaa697c0 Replace prettytable with terminaltables
It is a more advanced library and can deal with multi-line strings, what
allows us to present tags in a nice format. It also could nicely show
the table header, so we get rid of redundant Cluster column.

Debian/Ubuntu already have the module packaged as `python3-terminaltables`,
so it shouldn't be a problem for package maintainers.

Example output:
```bash
$ patronictl list
┌ Cluster: batman (6792870884189948744) ┬─────────┬────┬───────────┬─────────────────┬────────────────────────────┐
│ Member      │ Host           │ Role   │ State   │ TL │ Lag in MB │ Pending restart │ Tags                       │
├─────────────┼────────────────┼────────┼─────────┼────┼───────────┼─────────────────┼────────────────────────────┤
│ postgresql0 │ 127.0.0.1:5432 │ Leader │ running │  2 │           │                 │                            │
├─────────────┼────────────────┼────────┼─────────┼────┼───────────┼─────────────────┼────────────────────────────┤
│ postgresql1 │ 127.0.0.1:5433 │        │ running │  2 │         0 │                 │ clonefrom: true            │
│             │                │        │         │    │           │                 │ nofailover: true           │
│             │                │        │         │    │           │                 │ noloadbalance: true        │
│             │                │        │         │    │           │                 │ replicatefrom: postgresql0 │
├─────────────┼────────────────┼────────┼─────────┼────┼───────────┼─────────────────┼────────────────────────────┤
│ postgresql2 │ 127.0.0.1:5434 │        │ running │  2 │         0 │ *               │ replicatefrom: postgres1   │
└─────────────┴────────────────┴────────┴─────────┴────┴───────────┴─────────────────┴────────────────────────────┘

$ patronictl list badclustername
┌ Cluster: badclustername (uninitialized) ──────┐
│ Member │ Host │ Role │ State │ TL │ Lag in MB │
└────────┴──────┴──────┴───────┴────┴───────────┘

$ patronictl history
┌────┬──────────┬──────────────────────────────┬───────────────────────────┐
│ TL │      LSN │ Reason                       │ Timestamp                 │
├────┼──────────┼──────────────────────────────┼───────────────────────────┤
│  1 │ 25657792 │ no recovery target specified │ 2020-02-13T11:26:27+01:00 │
│  2 │ 25690088 │ no recovery target specified │ 2020-02-13T11:29:53+01:00 │
└────┴──────────┴──────────────────────────────┴───────────────────────────┘

$ patronictl query -c "SELECT a, repeat('x', a) from generate_series(1,3) a"
┌───┬────────┐
│ a │ repeat │
├───┼────────┤
│ 1 │ x      │
│ 2 │ xx     │
│ 3 │ xxx    │
└───┴────────┘
```
2020-02-14 12:38:35 +01:00
71 changed files with 646 additions and 4597 deletions
+29 -36
View File
@@ -7,23 +7,20 @@ addons:
- expect-dev # for unbuffer
env:
global:
- ETCDVERSION=3.3.13 ZKVERSION=3.4.14 CONSULVERSION=0.7.4
- PYVERSIONS="2.7 3.6"
- ETCDVERSION=3.0.17 ZKVERSION=3.4.14 CONSULVERSION=0.7.4
- PYVERSIONS="2.7 3.5 3.6"
- EXCLUDE_BEHAVE="3.5"
- BOTO_CONFIG=/doesnotexist
matrix:
include:
- python: "3.5"
env: PYVERSIONS="2.7 3.5 3.6" TEST_SUITE="python setup.py"
env: TEST_SUITE="python setup.py"
- python: "3.6"
env: DCS="etcd" TEST_SUITE="behave"
- python: "3.6"
env: DCS="etcd3" TEST_SUITE="behave"
- python: "3.6"
env: DCS="exhibitor" TEST_SUITE="behave"
- python: "3.6"
env: DCS="consul" TEST_SUITE="behave"
- python: "3.6"
env: DCS="raft" TEST_SUITE="behave"
- python: "3.6"
env: DCS="kubernetes" TEST_SUITE="behave"
branches:
@@ -37,8 +34,10 @@ before_cache:
- |
rm -fr $HOME/mycache/python*
for pv in $PYVERSIONS; do
fpv=$(basename $(readlink $HOME/virtualenv/python${pv}))
mv $HOME/virtualenv/${fpv} $HOME/mycache/${fpv}
if [[ $TEST_SUITE != "behave" || $pv != $EXCLUDE_BEHAVE ]]; then
fpv=$(basename $(readlink $HOME/virtualenv/python${pv}))
mv $HOME/virtualenv/${fpv} $HOME/mycache/${fpv}
fi
done
install:
- |
@@ -59,7 +58,6 @@ install:
function get_etcd() {
EC=~/mycache/etcd_${ETCDVERSION}
if [[ ! -x $EC ]]; then
rm -rf ~/mycache/etcd_*
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
@@ -68,10 +66,6 @@ install:
ln -s $EC etcd
}
function get_etcd3() {
get_etcd
}
function get_kubernetes() {
wget -O localkube "https://storage.googleapis.com/minikube/k8sReleases/v1.7.0/localkube-linux-amd64"
chmod +x localkube
@@ -108,10 +102,6 @@ install:
done&
}
function get_raft() {
return 0
}
attempt_num=1
until get_${DCS}; do
[[ $attempt_num -ge 3 ]] && exit 1
@@ -121,38 +111,41 @@ install:
fi
for pv in $PYVERSIONS; do
fpv=$(basename $(readlink $HOME/virtualenv/python$pv))
if [[ -d ~/mycache/${fpv} ]]; then
mv ~/virtualenv/${fpv} ~/virtualenv/${fpv}.bckp
mv ~/mycache/${fpv} ~/virtualenv/${fpv}
if [[ $TEST_SUITE != "behave" || $pv != $EXCLUDE_BEHAVE ]]; then
fpv=$(basename $(readlink $HOME/virtualenv/python$pv))
if [[ -d ~/mycache/${fpv} ]]; then
mv ~/virtualenv/${fpv} ~/virtualenv/${fpv}.bckp
mv ~/mycache/${fpv} ~/virtualenv/${fpv}
fi
source ~/virtualenv/python${pv}/bin/activate
# explicitly install all needed python modules to cache them
for p in '-r requirements.txt' 'psycopg2-binary behave codacy-coverage coverage coveralls flake8 mock pytest-cov pytest setuptools'; do
pip install $p --upgrade
done
fi
source ~/virtualenv/python${pv}/bin/activate
# explicitly install all needed python modules to cache them
for p in '-r requirements.txt' 'psycopg2-binary behave codacy-coverage coverage coveralls flake8 mock pytest-cov pytest setuptools'; do
pip install $p --upgrade
done
done
script:
- |
for pv in $PYVERSIONS; do
if [[ $TEST_SUITE == "behave" && $pv == $EXCLUDE_BEHAVE ]]; then
continue
fi
source ~/virtualenv/python${pv}/bin/activate
if [[ $TEST_SUITE = "behave" ]]; then
echo Running integration tests using python${pv}
if [[ $TEST_SUITE != "behave" ]]; then
echo Running unit tests using python${pv}
unbuffer $TEST_SUITE test
$TEST_SUITE flake8
elif [[ $pv != $EXCLUDE_BEHAVE ]]; then
echo Running acceptance tests using python${pv}
if ! PATH=.:/usr/lib/postgresql/9.6/bin:$PATH unbuffer $TEST_SUITE; then
# output all log files when tests are failing
grep . features/output/*_failed/*postgres?.*
exit 1
fi
else
echo Running unit tests using python${pv}
unbuffer $TEST_SUITE test
$TEST_SUITE flake8
fi
mv .coverage /tmp/.coverage.$pv
done
mv /tmp/.coverage.* .
python -m coverage combine
set +e
after_success:
+1 -1
View File
@@ -14,7 +14,7 @@ ARG PGDATA
ARG LC_ALL
ARG LANG
ENV ETCDVERSION=3.3.13 CONFDVERSION=0.16.0
ENV ETCDVERSION=2.3.8 CONFDVERSION=0.16.0
RUN set -ex \
&& export DEBIAN_FRONTEND=noninteractive \
+1 -3
View File
@@ -96,7 +96,7 @@ Patroni can be installed with pip:
where dependencies can be either empty, or consist of one or more of the following:
etcd or etcd3
etcd
`python-etcd` module in order to use Etcd as DCS
consul
`python-consul` module in order to use Consul as DCS
@@ -106,8 +106,6 @@ exhibitor
`kazoo` module in order to use Exhibitor as DCS (same dependencies as for Zookeeper)
kubernetes
`kubernetes` module in order to use Kubernetes as DCS in Patroni
raft
`pysyncobj` module in order to use python Raft implementation as DCS
aws
`boto` in order to use AWS callbacks
+35 -43
View File
@@ -6,31 +6,57 @@ networks:
demo:
services:
etcd1: &etcd
etcd1:
image: patroni
networks: [ demo ]
environment:
ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379
ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
ETCD_INITIAL_CLUSTER_STATE: new
ETCD_INITIAL_CLUSTER_TOKEN: tutorial
env_file: docker/etcd.env
container_name: demo-etcd1
hostname: etcd1
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
etcd2:
<<: *etcd
image: patroni
networks: [ demo ]
env_file: docker/etcd.env
container_name: demo-etcd2
hostname: etcd2
command: etcd -name etcd2 -initial-advertise-peer-urls http://etcd2:2380
etcd3:
<<: *etcd
image: patroni
networks: [ demo ]
env_file: docker/etcd.env
container_name: demo-etcd3
hostname: etcd3
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
patroni1:
image: patroni
networks: [ demo ]
env_file: docker/patroni.env
hostname: patroni1
container_name: demo-patroni1
environment:
PATRONI_NAME: patroni1
patroni2:
image: patroni
networks: [ demo ]
env_file: docker/patroni.env
hostname: patroni2
container_name: demo-patroni2
environment:
PATRONI_NAME: patroni2
patroni3:
image: patroni
networks: [ demo ]
env_file: docker/patroni.env
hostname: patroni3
container_name: demo-patroni3
environment:
PATRONI_NAME: patroni3
haproxy:
image: patroni
networks: [ demo ]
@@ -41,37 +67,3 @@ services:
- "5000:5000"
- "5001:5001"
command: haproxy
environment: &haproxy_env
ETCDCTL_ENDPOINTS: http://etcd1:2379,http://etcd2:2379,http://etcd3:2379
PATRONI_ETCD3_HOSTS: "'etcd1:2379','etcd2:2379','etcd3:2379'"
PATRONI_SCOPE: demo
patroni1:
image: patroni
networks: [ demo ]
env_file: docker/patroni.env
hostname: patroni1
container_name: demo-patroni1
environment:
<<: *haproxy_env
PATRONI_NAME: patroni1
patroni2:
image: patroni
networks: [ demo ]
env_file: docker/patroni.env
hostname: patroni2
container_name: demo-patroni2
environment:
<<: *haproxy_env
PATRONI_NAME: patroni2
patroni3:
image: patroni
networks: [ demo ]
env_file: docker/patroni.env
hostname: patroni3
container_name: demo-patroni3
environment:
<<: *haproxy_env
PATRONI_NAME: patroni3
+2 -2
View File
@@ -25,7 +25,7 @@ case "$1" in
while ! etcdctl cluster-health 2> /dev/null; do
sleep 1
done
exec dumb-init $CONFD etcdv3 -node $(echo $ETCDCTL_ENDPOINTS | sed 's/,/ -node /g')
exec dumb-init $CONFD etcd -node $(echo $ETCDCTL_ENDPOINTS | sed 's/,/ -node /g')
fi
;;
etcd)
@@ -37,7 +37,7 @@ case "$1" in
esac
## We start an etcd
if [ -z "$PATRONI_ETCD3_HOSTS" ] && [ -z "$PATRONI_ZOOKEEPER_HOSTS" ]; then
if [ -z "$PATRONI_ETCD_HOSTS" ] && [ -z "$PATRONI_ZOOKEEPER_HOSTS" ]; then
export PATRONI_ETCD_URL="http://127.0.0.1:2379"
etcd --data-dir /tmp/etcd.data -advertise-client-urls=$PATRONI_ETCD_URL -listen-client-urls=http://0.0.0.0:2379 > /var/log/etcd.log 2> /var/log/etcd.err &
fi
+5
View File
@@ -0,0 +1,5 @@
ETCD_LISTEN_PEER_URLS=http://0.0.0.0:2380
ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:2379
ETCD_INITIAL_CLUSTER=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
ETCD_INITIAL_CLUSTER_STATE=new
ETCD_INITIAL_CLUSTER_TOKEN=tutorial
+6
View File
@@ -1,3 +1,6 @@
PATRONI_SCOPE=demo
PATRONI_ETCD_HOSTS='etcd1:2379','etcd2:2379','etcd3:2379'
PATRONI_RESTAPI_USERNAME=admin
PATRONI_RESTAPI_PASSWORD=admin
PATRONI_SUPERUSER_USERNAME=postgres
@@ -6,3 +9,6 @@ PATRONI_REPLICATION_USERNAME=replicator
PATRONI_REPLICATION_PASSWORD=replicate
PATRONI_admin_PASSWORD=admin
PATRONI_admin_OPTIONS=createdb,createrole
# for etcdctl
ETCDCTL_ENDPOINTS=http://etcd1:2379,http://etcd2:2379,http://etcd3:2379
+3 -37
View File
@@ -66,27 +66,9 @@ Etcd
- **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.
Etcdv3
------
Environment names for Etcdv3 are similar as for Etcd, you just need to use ``ETCD3`` instead of ``ETCD`` in the variable name. Example: ``PATRONI_ETCD3_HOST``, ``PATRONI_ETCD3_CACERT``, and so on.
.. warning::
Keys created with protocol version 2 are not visible with protocol version 3 and the other way around, therefore it is not possible to switch from Etcd to Etcdv3 just by updating Patroni configuration.
ZooKeeper
---------
- **PATRONI\_ZOOKEEPER\_HOSTS**: Comma separated list of ZooKeeper cluster members: "'host1:port1','host2:port2','etc...'". It is important to quote every single entity!
- **PATRONI\_ZOOKEEPER\_USE\_SSL**: (optional) Whether SSL is used or not. Defaults to ``false``. If set to ``false``, all SSL specific parameters are ignored.
- **PATRONI\_ZOOKEEPER\_CACERT**: (optional) The CA certificate. If present it will enable validation.
- **PATRONI\_ZOOKEEPER\_CERT**: (optional) File with the client certificate.
- **PATRONI\_ZOOKEEPER\_KEY**: (optional) File with the client key.
- **PATRONI\_ZOOKEEPER\_KEY\_PASSWORD**: (optional) The client key password.
- **PATRONI\_ZOOKEEPER\_VERIFY**: (optional) Whether to verify certificate or not. Defaults to ``true``.
.. note::
It is required to install ``kazoo>=2.6.0`` to support SSL.
- **PATRONI\_ZOOKEEPER\_HOSTS**: comma separated list of ZooKeeper cluster members: "'host1:port1','host2:port2','etc...'". It is important to quote every single entity!
Exhibitor
---------
@@ -97,7 +79,6 @@ Exhibitor
Kubernetes
----------
- **PATRONI\_KUBERNETES\_BYPASS\_API\_SERVICE**: (optional) When communicating with the Kubernetes API, Patroni is usually relying on the `kubernetes` service, the address of which is exposed in the pods via the `KUBERNETES_SERVICE_HOST` environment variable. If `PATRONI_KUBERNETES_BYPASS_API_SERVICE` is set to ``true``, Patroni will resolve the list of API nodes behind the service and connect directly to them.
- **PATRONI\_KUBERNETES\_NAMESPACE**: (optional) Kubernetes namespace where the Patroni pod is running. Default value is `default`.
- **PATRONI\_KUBERNETES\_LABELS**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
- **PATRONI\_KUBERNETES\_SCOPE\_LABEL**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
@@ -106,13 +87,6 @@ Kubernetes
- **PATRONI\_KUBERNETES\_POD\_IP**: (optional) IP address of the pod Patroni is running in. This value is required when `PATRONI_KUBERNETES_USE_ENDPOINTS` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
- **PATRONI\_KUBERNETES\_PORTS**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``PATRONI_KUBERNETES_PORTS='[{"name": "postgresql", "port": 5432}]'`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `PATRONI_KUBERNETES_USE_ENDPOINTS` is set.
Raft
----
- **PATRONI\_RAFT\_SELF\_ADDR**: ``ip:port`` to listen on for Raft connections. If not set, the node will not participate in consensus.
- **PATRONI\_RAFT\_PARTNER\_ADDRS**: list of other Patroni nodes in the cluster in format ``"'ip1:port1','ip2:port2'"``. It is important to quote every single entity!
- **PATRONI\_RAFT\_DATA\_DIR**: directory where to store Raft log and snapshot. If not specified the current working directory is used.
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.
@@ -128,8 +102,6 @@ PostgreSQL
- **PATRONI\_REPLICATION\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **PATRONI\_REPLICATION\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **PATRONI\_REPLICATION\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_REPLICATION\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **PATRONI\_REPLICATION\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **PATRONI\_SUPERUSER\_USERNAME**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres. Also this user is used by pg_rewind.
- **PATRONI\_SUPERUSER\_PASSWORD**: password for the superuser, set during initialization (initdb).
- **PATRONI\_SUPERUSER\_SSLMODE**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
@@ -137,8 +109,6 @@ PostgreSQL
- **PATRONI\_SUPERUSER\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **PATRONI\_SUPERUSER\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **PATRONI\_SUPERUSER\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_SUPERUSER\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **PATRONI\_SUPERUSER\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **PATRONI\_REWIND\_USERNAME**: name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
- **PATRONI\_REWIND\_PASSWORD**: password for the user for ``pg_rewind``; the user will be created during initialization.
- **PATRONI\_REWIND\_SSLMODE**: (optional) maps to the `sslmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE>`__ connection parameter, which allows a client to specify the type of TLS negotiation mode with the server. For more information on how each mode works, please visit the `PostgreSQL documentation <https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS>`__. The default mode is ``prefer``.
@@ -146,8 +116,6 @@ PostgreSQL
- **PATRONI\_REWIND\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **PATRONI\_REWIND\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **PATRONI\_REWIND\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_REWIND\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **PATRONI\_REWIND\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
REST API
--------
@@ -158,13 +126,11 @@ REST API
- **PATRONI\_RESTAPI\_CERTFILE**: Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
- **PATRONI\_RESTAPI\_KEYFILE**: Specifies the file with the secret key in the PEM format.
- **PATRONI\_RESTAPI\_CAFILE**: Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
- **PATRONI\_RESTAPI\_VERIFY\_CLIENT**: ``none`` (default), ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. When ``required`` is used, then client authentication succeeds, if the certificate signature verification succeeds. For ``optional`` the client cert will only be checked for ``PUT``, ``POST``, ``PATCH``, and ``DELETE`` requests.
- **PATRONI\_RESTAPI\_HTTP\_EXTRA\_HEADERS**: (optional) HTTP headers let the REST API server pass additional information with an HTTP response.
- **PATRONI\_RESTAPI\_HTTPS\_EXTRA\_HEADERS**: (optional) HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
- **PATRONI\_RESTAPI\_VERIFY\_CLIENT**: ``none`` (default), ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. When ``required`` is used, then client authentication succeeds, if the certificate signature verification succeeds. For ``optional`` the client cert will only be checked for ``PUT``, ``POST``, ``PATCH``, and ``DELETE`` requests.
CTL
---
- **PATRONICTL\_CONFIG\_FILE**: location of the configuration file.
- **PATRONICTL\_CONFIG\_FILE**: location of the configuration file.
- **PATRONI\_CTL\_INSECURE**: Allow connections to REST API without verifying SSL certs.
- **PATRONI\_CTL\_CACERT**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
- **PATRONI\_CTL\_CERTFILE**: Specifies the file with the client certificate in the PEM format. If not provided patronictl will use the value provided for REST API "certfile" parameter.
+1 -10
View File
@@ -72,7 +72,7 @@ Patroni can be installed with pip:
where dependencies can be either empty, or consist of one or more of the following:
etcd or etcd3
etcd
`python-etcd` module in order to use Etcd as DCS
consul
`python-consul` module in order to use Consul as DCS
@@ -82,8 +82,6 @@ exhibitor
`kazoo` module in order to use Exhibitor as DCS (same dependencies as for Zookeeper)
kubernetes
`kubernetes` module in order to use Kubernetes as DCS in Patroni
raft
`pysyncobj` module in order to use python Raft implementation as DCS
aws
`boto` in order to use AWS callbacks
@@ -99,13 +97,6 @@ independently of Patroni.
.. _running_configuring:
Planning the Number of PostgreSQL Nodes
---------------------------------------
Patroni/PostgreSQL nodes are decoupled from DCS nodes (except when Patroni implements RAFT on its own) and therefore
there is no requirement on the minimal number of nodes. Running a cluster consisting of one master and one standby is
perfectly fine. You can add more standby nodes later.
Running and Configuring
-----------------------
+2 -97
View File
@@ -103,23 +103,8 @@ Most of the parameters are optional, but you have to specify one of the **host**
- **consistency**: (optional) Select consul consistency mode. Possible values are ``default``, ``consistent``, or ``stale`` (more details in `consul API reference <https://www.consul.io/api/features/consistency.html/>`__)
- **checks**: (optional) list of Consul health checks used for the session. By default an empty list is used.
- **register\_service**: (optional) whether or not to register a service with the name defined by the scope parameter and the tag master, replica or standby-leader depending on the node's role. Defaults to **false**.
- **service\_tags**: (optional) additional static tags to add to the Consul service apart from the role (``master``/``replica``/``standby-leader``). By default an empty list is used.
- **service\_check\_interval**: (optional) how often to perform health check against registered url.
The ``token`` needs to have the following ACL permissions:
::
service_prefix "${scope}" {
policy = "write"
}
key_prefix "${namespace}/${scope}" {
policy = "write"
}
session_prefix "" {
policy = "write"
}
Etcd
----
Most of the parameters are optional, but you have to specify one of the **host**, **hosts**, **url**, **proxy** or **srv**
@@ -137,27 +122,9 @@ Most of the parameters are optional, but you have to specify one of the **host**
- **cert**: (optional) file with the client certificate.
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
Etcdv3
------
If you want that Patroni works with Etcd cluster via protocol version 3, you need to use the ``etcd3`` section in the Patroni configuration file. All configuration parameters are the same as for ``etcd``.
.. warning::
Keys created with protocol version 2 are not visible with protocol version 3 and the other way around, therefore it is not possible to switch from ``etcd`` to ``etcd3`` just by updating Patroni config file.
ZooKeeper
----------
- **hosts**: List of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...'].
- **use_ssl**: (optional) Whether SSL is used or not. Defaults to ``false``. If set to ``false``, all SSL specific parameters are ignored.
- **cacert**: (optional) The CA certificate. If present it will enable validation.
- **cert**: (optional) File with the client certificate.
- **key**: (optional) File with the client key.
- **key_password**: (optional) The client key password.
- **verify**: (optional) Whether to verify certificate or not. Defaults to ``true``.
.. note::
It is required to install ``kazoo>=2.6.0`` to support SSL.
- **hosts**: list of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...'].
Exhibitor
---------
@@ -169,7 +136,6 @@ Exhibitor
Kubernetes
----------
- **bypass\_api\_service**: (optional) When communicating with the Kubernetes API, Patroni is usually relying on the `kubernetes` service, the address of which is exposed in the pods via the `KUBERNETES_SERVICE_HOST` environment variable. If `bypass_api_service` is set to ``true``, Patroni will resolve the list of API nodes behind the service and connect directly to them.
- **namespace**: (optional) Kubernetes namespace where Patroni pod is running. Default value is `default`.
- **labels**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
- **scope\_label**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
@@ -178,38 +144,6 @@ Kubernetes
- **pod\_ip**: (optional) IP address of the pod Patroni is running in. This value is required when `use_endpoints` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
- **ports**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``kubernetes.ports: [{"name": "postgresql", "port": 5432}]`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `kubernetes.use_endpoints` is set.
.. _raft_settings:
Raft
----
- **self\_addr**: ``ip:port`` to listen on for Raft connections. If not set, the node will not participate in consensus.
- **partner\_addrs**: list of other Patroni nodes in the cluster in format: ['ip1:port', 'ip2:port', 'etc...']
- **data\_dir**: directory where to store Raft log and snapshot. If not specified the current working directory is used.
Short FAQ about Raft implementation
- Q: How to list all the nodes providing consensus?
A: ``syncobj_admin -conn host:port`` -status where the host:port is the address of one of the cluster nodes
- Q: Node that was a part of consensus and has gone and I can't reuse the same IP for other node. How to remove this node from the consensus?
A: ``syncobj_admin -conn host:port -remove host2:port2`` where the ``host2:port2`` is the address of the node you want to remove from consensus.
- Q: Where to get the ``syncobj_admin`` utility?
A: It is installed together with ``pysyncobj`` module (python RAFT implementation), which is Patroni dependancy.
- Q: it is possible to run Patroni node without adding in to the consensus?
A: Yes, just comment out or remove ``raft.self_addr`` from Patroni configuration.
- Q: It is possible to run Patroni and PostgreSQL only on two nodes?
A: Yes, on the third node you can run ``patroni_raft_controller`` (without Patroni and PostgreSQL). In such setup one can temporary loose one node without affecting the primary.
.. _postgresql_settings:
PostgreSQL
@@ -224,8 +158,6 @@ PostgreSQL
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **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.
@@ -234,8 +166,6 @@ PostgreSQL
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **rewind**:
- **username**: name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
- **password**: password for the user for ``pg_rewind``; the user will be created during initialization.
@@ -244,8 +174,6 @@ PostgreSQL
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **callbacks**: callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. (See scripts/aws.py as an example of how to write them.)
- **on\_reload**: run this script when configuration reload is triggered.
- **on\_restart**: run this script when the postgres restarts (without changing role).
@@ -276,13 +204,12 @@ PostgreSQL
- **remove\_data\_directory\_on\_rewind\_failure**: If this option is enabled, Patroni will remove the PostgreSQL data directory and recreate the replica. Otherwise it will try to follow the new leader. Default value is **false**.
- **remove\_data\_directory\_on\_diverged\_timelines**: Patroni will remove the PostgreSQL data directory and recreate the replica if it notices that timelines are diverging and the former master can not start streaming from the new master. This option is useful when ``pg_rewind`` can not be used. Default value is **false**.
- **replica\_method**: for each create_replica_methods other than basebackup, you would add a configuration section of the same name. At a minimum, this should include "command" with a full path to the actual script to be executed. Other configuration parameters will be passed along to the script in the form "parameter=value".
- **pre\_promote**: a fencing script that executes during a failover after acquiring the leader lock but before promoting the replica. If the script exits with a non-zero code, Patroni does not promote the replica and removes the leader key from DCS.
REST API
--------
- **restapi**:
- **connect\_address**: IP address (or hostname) and port, to access the Patroni's :ref:`REST API <rest_api>`. All the members of the cluster must be able to connect to this address, so unless the Patroni setup is intended for a demo inside the localhost, this address must be a non "localhost" or loopback address (ie: "localhost" or "127.0.0.1"). It can serve as an endpoint for HTTP health checks (read below about the "listen" REST API parameter), and also for user queries (either directly or via the REST API), as well as for the health checks done by the cluster members during leader elections (for example, to determine whether the master is still running, or if there is a node which has a WAL position that is ahead of the one doing the query; etc.) The connect_address is put in the member key in DCS, making it possible to translate the member name into the address to connect to its REST API.
- **listen**: IP address (or hostname) and port that Patroni will listen to for the REST API - to provide also the same health checks and cluster messaging between the participating nodes, as described above. to provide health-check information for HAProxy (or any other load balancer capable of doing a HTTP "OPTION" or "GET" checks).
- **authentication**: (optional)
@@ -292,28 +219,6 @@ REST API
- **keyfile**: (optional): Specifies the file with the secret key in the PEM format.
- **cafile**: (optional): Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
- **verify\_client**: (optional): ``none`` (default), ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. When ``required`` is used, then client authentication succeeds, if the certificate signature verification succeeds. For ``optional`` the client cert will only be checked for ``PUT``, ``POST``, ``PATCH``, and ``DELETE`` requests.
- **http\_extra\_headers**: (optional): HTTP headers let the REST API server pass additional information with an HTTP response.
- **https\_extra\_headers**: (optional): HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
Here is an example of both **http_extra_headers** and **https_extra_headers**:
.. code:: YAML
restapi:
listen: <listen>
connect_address: <connect_address>
authentication:
username: <username>
password: <password>
http_extra_headers:
'X-Frame-Options': 'SAMEORIGIN'
'X-XSS-Protection': '1; mode=block'
'X-Content-Type-Options': 'nosniff'
cafile: <ca file>
certfile: <cert>
keyfile: <key>
https_extra_headers:
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
.. _patronictl_settings:
-3
View File
@@ -20,8 +20,6 @@ Patroni configuration is stored in the DCS (Distributed Configuration Store). Th
It is possible to set/override some of the "Local" configuration parameters with environment variables.
Environment configuration is very useful when you are running in a dynamic environment and you don't know some of the parameters in advance (for example it's not possible to know your external IP address when you are running inside ``docker``).
The local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence.
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
@@ -37,7 +35,6 @@ For the parameters below, PostgreSQL does not require equal values among the mas
- max_wal_senders: 5
- max_replication_slots: 5
- wal_keep_segments: 8
- wal_keep_size: 128MB
These parameters are validated to ensure they are sane, or meet a minimum value.
-15
View File
@@ -23,21 +23,6 @@ A Patroni cluster can be started with a data directory from a single-node Postgr
3. Start Patroni (e.g. ``patroni /etc/patroni/patroni.yml``). It automatically detects that PostgreSQL daemon is already running but its configuration might be out-of-date.
4. Ask Patroni to restart the node with ``patronictl restart cluster-name node-name``. This step is only required if PostgreSQL configuration is out-of-date.
Major Upgrade of PostgreSQL Version
===================================
The only possible way to do a major upgrade currently is:
1. Stop Patroni
2. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the master node
3. Update patroni.yml
4. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running ``patronictl remove <cluster-name>``. It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier.
5. If you wiped the cluster state in the previous step, you may wish to copy patroni.dynamic.json from old data dir to the new one. It will help you to retain some PostgreSQL parameters you had set before.
6. Start Patroni on the master node.
7. Upgrade PostgreSQL binaries, update patroni.yml and wipe the data_dir on standby nodes.
8. Start Patroni on the standby nodes and wait for the replication to complete.
Running pg_upgrade on standby nodes is not supported by PostgreSQL. If you know what you are doing, you can try the rsync procedure described in https://www.postgresql.org/docs/current/pgupgrade.html instead of wiping data_dir on standby nodes. The safest way is however to let Patroni replicate the data for you.
FAQ
---
-1
View File
@@ -22,7 +22,6 @@ We call Patroni a "template" because it is far from being a one-size-fits-all or
rest_api
ENVIRONMENT
SETTINGS
security
replica_bootstrap
replication_modes
pause
-222
View File
@@ -3,228 +3,6 @@
Release notes
=============
Version 2.0.0
-------------
This version enhances compatibility with PostgreSQL 13, adds support of multiple synchronous standbys, has significant improvements in handling of ``pg_rewind``, adds support of Etcd v3 and Patroni on pure RAFT (without Etcd, Consul, or Zookeeper), and makes it possible to optionally call the ``pre_promote`` (fencing) script.
**PostgreSQL 13 support**
- Don't fire ``on_reload`` when promoting to ``standby_leader`` on PostgreSQL 13+ (Alexander Kukushkin)
When promoting to ``standby_leader`` we change ``primary_conninfo``, update the role and reload Postgres. Since ``on_role_change`` and ``on_reload`` effectively duplicate each other, Patroni will call only ``on_role_change``.
- Added support for ``gssencmode`` and ``channel_binding`` connection parameters (Alexander)
PostgreSQL 12 introduced ``gssencmode`` and 13 ``channel_binding`` connection parameters and now they can be used if defined in the ``postgresql.authentication`` section.
- Handle renaming of ``wal_keep_segments`` to ``wal_keep_size`` (Alexander)
In case of misconfiguration (``wal_keep_segments`` on 13 and ``wal_keep_size`` on older versions) Patroni will automatically adjust the configuration.
- Use ``pg_rewind`` with ``--restore-target-wal`` on 13 if possible (Alexander)
On PostgreSQL 13 Patroni checks if ``restore_command`` is configured and tells ``pg_rewind`` to use it.
**New features**
- [BETA] Implemented support of Patroni on pure RAFT (Alexander)
This makes it possible to run Patroni without 3rd party dependencies, like Etcd, Consul, or Zookeeper. For HA you will have to run either three Patroni nodes or two nodes with Patroni and one node with ``patroni_raft_controller``. For more information please check the :ref:`documentation <raft_settings>`.
- [BETA] Implemented support for Etcd v3 protocol via gPRC-gateway (Alexander)
Etcd 3.0 was released more than four years ago and Etcd 3.4 has v2 disabled by default. There are also chances that v2 will be completely removed from Etcd, therefore we implemented support of Etcd v3 in Patroni. In order to start using it you have to explicitly create the ``etcd3`` section is the Patroni configuration file.
- Supporting multiple synchronous standbys (Krishna Sarabu)
It allows running a cluster with more than one synchronous replicas. The maximum number of synchronous replicas is controlled by the new parameter ``synchronous_node_count``. It is set to 1 by default and has no effect when the ``synchronous_mode`` is set to ``off``.
- Added possibility to call the ``pre_promote`` script (Sergey Dudoladov)
Unlike callbacks, the ``pre_promote`` script is called synchronously after acquiring the leader lock, but before promoting Postgres. If the script fails or exits with a non-zero exitcode, the current node will release the leader lock.
- Added support for configuration directories (Floris van Nee)
YAML files in the directory loaded and applied in alphabetical order.
- Advanced validation of PostgreSQL parameters (Alexander)
In case the specific parameter is not supported by the current PostgreSQL version or when its value is incorrect, Patroni will remove the parameter completely or try to fix the value.
- Wake up the main thread when the forced checkpoint after promote completed (Alexander)
Replicas are waiting for checkpoint indication via member key of the leader in DCS. The key is normally updated only once per HA loop. Without waking the main thread up, replicas will have to wait up to ``loop_wait`` seconds longer than necessary.
- Use of ``pg_stat_wal_recevier`` view on 9.6+ (Alexander)
The view contains up-to-date values of ``primary_conninfo`` and ``primary_slot_name``, while the contents of ``recovery.conf`` could be stale.
- Improved handing of IPv6 addresses in the Patroni config file (Mateusz Kowalski)
The IPv6 address is supposed to be enclosed into square brackets, but Patroni was expecting to get it plain. Now both formats are supported.
- Added Consul ``service_tags`` configuration parameter (Robert Edström)
They are useful for dynamic service discovery, for example by load balancers.
- Implemented SSL support for Zookeeper (Kostiantyn Nemchenko)
It requires ``kazoo>=2.6.0``.
- Implemented ``no_params`` option for custom bootstrap method (Kostiantyn)
It allows calling ``wal-g``, ``pgBackRest`` and other backup tools without wrapping them into shell scripts.
- Move WAL and tablespaces after a failed init (Feike Steenbergen)
When doing ``reinit``, Patroni was already removing not only ``PGDATA`` but also the symlinked WAL directory and tablespaces. Now the ``move_data_directory()`` method will do a similar job, i.e. rename WAL directory and tablespaces and update symlinks in PGDATA.
**Improved in pg_rewind support**
- Improved timeline divergence check (Alexander)
We don't need to rewind when the replayed location on the replica is not ahead of the switchpoint or the end of the checkpoint record on the former primary is the same as the switchpoint. In order to get the end of the checkpoint record we use ``pg_waldump`` and parse its output.
- Try to fetch missing WAL if ``pg_rewind`` complains about it (Alexander)
It could happen that the WAL segment required for ``pg_rewind`` doesn't exist in the ``pg_wal`` directory anymore and therefore ``pg_rewind`` can't find the checkpoint location before the divergence point. Starting from PostgreSQL 13 ``pg_rewind`` could use ``restore_command`` for fetching missing WALs. For older PostgreSQL versions Patroni parses the errors of a failed rewind attempt and tries to fetch the missing WAL by calling the ``restore_command`` on its own.
- Detect a new timeline in the standby cluster and trigger rewind/reinitialize if necessary (Alexander)
The ``standby_cluster`` is decoupled from the primary cluster and therefore doesn't immediately know about leader elections and timeline switches. In order to detect the fact, the ``standby_leader`` periodically checks for new history files in ``pg_wal``.
- Shorten and beautify history log output (Alexander)
When Patroni is trying to figure out the necessity of ``pg_rewind``, it could write the content of the history file from the primary into the log. The history file is growing with every failover/switchover and eventually starts taking up too many lines, most of which are not so useful. Instead of showing the raw data, Patroni will show only 3 lines before the current replica timeline and 2 lines after.
**Improvements on K8s**
- Get rid of ``kubernetes`` python module (Alexander)
The official python kubernetes client contains a lot of auto-generated code and therefore very heavy. Patroni uses only a small fraction of K8s API endpoints and implementing support for them wasn't hard.
- Make it possible to bypass the ``kubernetes`` service (Alexander)
When running on K8s, Patroni is usually communicating with the K8s API via the ``kubernetes`` service, the address of which is exposed in the ``KUBERNETES_SERVICE_HOST`` environment variable. Like any other service, the ``kubernetes`` service is handled by ``kube-proxy``, which in turn, depending on the configuration, is either relying on a userspace program or ``iptables`` for traffic routing. Skipping the intermediate component and connecting directly to the K8s master nodes allows us to implement a better retry strategy and mitigate risks of demoting Postgres when K8s master nodes are upgraded.
- Sync HA loops of all pods of a Patroni cluster (Alexander)
Not doing so was increasing failure detection time from ``ttl`` to ``ttl + loop_wait``.
- Populate ``references`` and ``nodename`` in the subsets addresses on K8s (Alexander)
Some load-balancers are relying on this information.
- Fix possible race conditions in the ``update_leader()`` (Alexander)
The concurrent update of the leader configmap or endpoint happening outside of Patroni might cause the ``update_leader()`` call to fail. In this case Patroni rechecks that the current node is still owning the leader lock and repeats the update.
- Explicitly disallow patching non-existent config (Alexander)
For DCS other than ``kubernetes`` the PATCH call is failing with an exception due to ``cluster.config`` being ``None``, but on Kubernetes it was happily creating the config annotation and preventing writing bootstrap configuration after the bootstrap finished.
- Fix bug in ``pause`` (Alexander)
Replicas were removing ``primary_conninfo`` and restarting Postgres when the leader key was absent, but they should do nothing.
**Improvements in REST API**
- Defer TLS handshake until worker thread has started (Alexander, Ben Harris)
If the TLS handshake was done in the API thread and the client-side didn't send any data, the API thread was blocked (risking DoS).
- Check ``basic-auth`` independently from client certificate in REST API (Alexander)
Previously only the client certificate was validated. Doing two checks independently is an absolutely valid use-case.
- Write double ``CRLF`` after HTTP headers of the ``OPTIONS`` request (Sergey Burladyan)
HAProxy was happy with a single ``CRLF``, while Consul health-check complained about broken connection and unexpected EOF.
- ``GET /cluster`` was showing stale members info for Zookeeper (Alexander)
The endpoint was using the Patroni internal cluster view. For Patroni itself it didn't cause any issues, but when exposed to the outside world we need to show up-to-date information, especially replication lag.
- Fixed health-checks for standby cluster (Alexander)
The ``GET /standby-leader`` for a master and ``GET /master`` for a ``standby_leader`` were incorrectly responding with 200.
- Implemented ``DELETE /switchover`` (Alexander)
The REST API call deletes the scheduled switchover.
- Created ``/readiness`` and ``/liveness`` endpoints (Alexander)
They could be useful to eliminate "unhealthy" pods from subsets addresses when the K8s service is used with label selectors.
- Enhanced ``GET /replica`` and ``GET /async`` REST API health-checks (Krishna, Alexander)
Checks now support optional keyword ``?lag=<max-lag>`` and will respond with 200 only if the lag is smaller than the supplied value. If relying on this feature please keep in mind that information about WAL position on the leader is updated only every ``loop_wait`` seconds!
- Added support for user defined HTTP headers in the REST API response (Yogesh Sharma)
This feature might be useful if requests are made from a browser.
**Improvements in patronictl**
- Don't try to call non-existing leader in ``patronictl pause`` (Alexander)
While pausing a cluster without a leader on K8s, ``patronictl`` was showing warnings that member "None" could not be accessed.
- Handle the case when member ``conn_url`` is missing (Alexander)
On K8s it is possible that the pod doesn't have the necessary annotations because Patroni is not yet running. It was making ``patronictl`` to fail.
- Added ability to print ASCII cluster topology (Maxim Fedotov, Alexander)
It is very useful to get overview of the cluster with cascading replication.
- Implement ``patronictl flush switchover`` (Alexander)
Before that ``patronictl flush`` only supported cancelling scheduled restarts.
**Bugfixes**
- Attribute error during bootstrap of the cluster with existing PGDATA (Krishna)
When trying to create/update the ``/history`` key, Patroni was accessing the ``ClusterConfig`` object which wasn't created in DCS yet.
- Improved exception handling in Consul (Alexander)
Unhandled exception in the ``touch_member()`` method caused the whole Patroni process to crash.
- Enforce ``synchronous_commit=local`` for the ``post_init`` script (Alexander)
Patroni was already doing that when creating users (``replication``, ``rewind``), but missing it in the case of ``post_init`` was an oversight. As a result, if the script wasn't doing it internally on it's own the bootstrap in ``synchronous_mode`` wasn't able to finish.
- Increased ``maxsize`` in the Consul pool manager (ponvenkates)
With the default ``size=1`` some warnings were generated.
- Patroni was wrongly reporting Postgres as running (Alexander)
The state wasn't updated when for example Postgres crashed due to an out-of-disk error.
- Put ``*`` into ``pgpass`` instead of missing or empty values (Alexander)
If for example the ``standby_cluster.port`` is not specified, the ``pgpass`` file was incorrectly generated.
- Skip physical replication slot creation on the leader node with special characters (Krishna)
Patroni appeared to be creating a dormant slot (when ``slots`` defined) for the leader node when the name contained special chars such as '-' (for e.g. "abc-us-1").
- Avoid removing non-existent ``pg_hba.conf`` in the custom bootstrap (Krishna)
Patroni was failing if ``pg_hba.conf`` happened to be located outside of the ``pgdata`` dir after custom bootstrap.
Version 1.6.5
-------------
-3
View File
@@ -24,7 +24,6 @@ arguments to them, i.e. the name of the cluster and the path to the data directo
<custom_bootstrap_method_name>:
command: <path_to_custom_bootstrap_script> [param1 [, ...]]
keep_existing_recovery_conf: False
no_params: False
recovery_conf:
recovery_target_action: promote
recovery_target_timeline: latest
@@ -41,8 +40,6 @@ in the configuration files, Patroni supplies two cluster-specific ones:
--datadir
Path to the data directory of the cluster instance to be bootstrapped
Passing these two additional flags can be disabled by setting a special ``no_params`` parameter to ``True``.
If the bootstrap script returns 0, Patroni tries to configure and start the PostgreSQL instance produced by it. If any
of the intermediate steps fail, or the script returns a non-zero value, Patroni assumes that the bootstrap has failed,
cleans up after itself and releases the initialize lock to give another node the opportunity to bootstrap.
+3 -6
View File
@@ -59,14 +59,11 @@ Synchronous mode can be switched on and off via Patroni REST interface. See :ref
Note: Because of the way synchronous replication is implemented in PostgreSQL it is still possible to lose transactions even when using ``synchronous_mode_strict``. If the PostgreSQL backend is cancelled while waiting to acknowledge replication (as a result of packet cancellation due to client timeout or backend failure) transaction changes become visible for other backends. Such changes are not yet replicated and may be lost in case of standby promotion.
Synchronous Replication Factor
------------------------------
The parameter ``synchronous_node_count`` is used by Patroni to manage number of synchronous standby databases. It is set to 1 by default. It has no effect when ``synchronous_mode`` is set to off. When enabled, Patroni manages precise number of synchronous standby databases based on parameter ``synchronous_node_count`` and adjusts the state in DCS & synchronous_standby_names as members join and leave.
Synchronous mode implementation
-------------------------------
When in synchronous mode Patroni maintains synchronization state in the DCS, containing the latest primary and current synchronous standby databases. This state is updated with strict ordering constraints to ensure the following invariants:
When in synchronous mode Patroni maintains synchronization state in the DCS, containing the latest primary and current synchronous standby. This state is updated with strict ordering constraints to ensure the following invariants:
- A node must be marked as the latest leader whenever it can accept write transactions. Patroni crashing or PostgreSQL not shutting down can cause violations of this invariant.
@@ -74,9 +71,9 @@ When in synchronous mode Patroni maintains synchronization state in the DCS, con
- A node that is not the leader or current synchronous standby is not allowed to promote itself automatically.
Patroni will only assign one or more synchronous standby nodes based on ``synchronous_node_count`` parameter to ``synchronous_standby_names``.
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 nodes choice. If the current list of synchronous standby nodes are connected and has not requested its synchronous status to be removed it remains picked. Otherwise the cluster member available for sync that is furthest ahead in replication is picked.
On each HA loop iteration Patroni re-evaluates synchronous standby choice. If the current synchronous standby is connected and has not requested its synchronous status to be removed it remains picked. Otherwise the cluster member available for sync that is furthest ahead in replication is picked.
.. [1] The data is still there, but recovering it requires a manual recovery effort by data recovery specialists. When Patroni is allowed to rewind with ``use_pg_rewind`` the forked timeline will be automatically erased to rejoin the failed primary with the cluster.
+5 -45
View File
@@ -19,13 +19,6 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
- ``GET /replica``: replica health check endpoint. It returns HTTP status code **200** only when the Patroni node is in the state ``running``, the role is ``replica`` and ``noloadbalance`` tag is not set.
- ``GET /replica?lag=<max-lag>``: replica check endpoint. In addition to checks from ``replica``, it also checks replication latency and returns status code **200** only when it is below specified value. The key cluster.last_leader_operation from DCS is used for Leader wal position and compute latency on replica for performance reasons. max-lag can be specified in bytes (integer) or in human readable values, for e.g. 16kB, 64MB, 1GB.
- ``GET /replica?lag=1048576``
- ``GET /replica?lag=1024kB``
- ``GET /replica?lag=10MB``
- ``GET /replica?lag=1GB``
- ``GET /read-only``: like the above endpoint, but also includes the primary.
- ``GET /standby-leader``: returns HTTP status code **200** only when the Patroni node is running as the leader in a :ref:`standby cluster <standby_cluster>`.
@@ -34,45 +27,8 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
- ``GET /asynchronous`` or ``GET /async``: returns HTTP status code **200** only when the Patroni node is running as an asynchronous standby.
- ``GET /asynchronous?lag=<max-lag>`` or ``GET /async?lag=<max-lag>``: asynchronous standby check endpoint. In addition to checks from ``asynchronous`` or ``async``, it also checks replication latency and returns status code **200** only when it is below specified value. The key cluster.last_leader_operation from DCS is used for Leader wal position and compute latency on replica for performance reasons. max-lag can be specified in bytes (integer) or in human readable values, for e.g. 16kB, 64MB, 1GB.
- ``GET /async?lag=1048576``
- ``GET /async?lag=1024kB``
- ``GET /async?lag=10MB``
- ``GET /async?lag=1GB``
- ``GET /health``: returns HTTP status code **200** only when PostgreSQL is up and running.
- ``GET /liveness``: always returns HTTP status code **200** what only indicates that Patroni is running. Could be used for ``livenessProbe``.
- ``GET /readiness``: returns HTTP status code **200** when the Patroni node is running as the leader or when PostgreSQL is up and running. The endpoint could be used for ``readinessProbe`` when it is not possible to use Kubenetes endpoints for leader elections (OpenShift).
Both, ``readiness`` and ``liveness`` endpoints are very light-weight and not executing any SQL. Probes should be configured in such a way that they start failing about time when the leader key is expiring. With the default value of ``ttl``, which is ``30s`` example probes would look like:
.. code-block:: yaml
readinessProbe:
httpGet:
scheme: HTTP
path: /readiness
port: 8008
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 5
successThreshold: 1
failureThreshold: 3
livenessProbe:
httpGet:
scheme: HTTP
path: /liveness
port: 8008
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 5
successThreshold: 1
failureThreshold: 3
Monitoring endpoint
-------------------
@@ -193,6 +149,7 @@ Config endpoint
"parameters": {
"hot_standby": "on",
"wal_log_hints": "on",
"wal_keep_segments": 8,
"wal_level": "hot_standby",
"max_wal_senders": 5,
"max_replication_slots": 5,
@@ -220,6 +177,7 @@ Config endpoint
"parameters": {
"hot_standby": "on",
"wal_log_hints": "on",
"wal_keep_segments": 8,
"wal_level": "hot_standby",
"max_wal_senders": 5,
"max_replication_slots": 5,
@@ -271,6 +229,7 @@ If you want to remove (reset) some setting just patch it with ``null``:
"parameters": {
"hot_standby": "on",
"unix_socket_directories": ".",
"wal_keep_segments": 8,
"wal_level": "hot_standby",
"wal_log_hints": "on",
"max_wal_senders": 5,
@@ -286,7 +245,7 @@ The above call removes ``postgresql.parameters.max_connections`` from the dynami
.. 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_level":"hot_standby","unix_socket_directories":".","max_wal_senders":5}},"loop_wait":3,"ttl":20}' \
'{"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,
@@ -297,6 +256,7 @@ The above call removes ``postgresql.parameters.max_connections`` from the dynami
"parameters": {
"hot_standby": "on",
"unix_socket_directories": ".",
"wal_keep_segments": 8,
"wal_level": "hot_standby",
"wal_log_hints": "on",
"max_wal_senders": 5
-37
View File
@@ -1,37 +0,0 @@
.. _security:
=======================
Security Considerations
=======================
A Patroni cluster has two interfaces to be protected from unauthorized access: the distributed configuration storage (DCS) and the Patroni REST API.
Protecting DCS
==============
Patroni and patronictl both store and retrieve data to/from the DCS.
Despite DCS doesn't contain any sensitive information, it allows changing some of Patroni/Postgres configuration. Therefore the very first thing that should be protected is DCS itself.
The details of protection depend on the type of DCS used. The authentication and encryption parameters (tokens/basic-auth/client certificates) for the supported types of DCS are covered in :ref:`SETTINGS <bootstrap_settings>`
The general recommendation is to enable TLS for all DCS communication.
Protecting the REST API
=======================
Protecting the REST API is a more complicated task.
The Patroni REST API is used by Patroni itself during the leader race, by the ``patronictl`` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring.
From the point of view of security, REST API contains safe (``GET`` requests, only retrieve information) and unsafe (``PUT``, ``POST``, ``PATCH`` and ``DELETE`` requests, change the state of nodes) endpoints.
The unsafe endpoints can be protected with HTTP basic-auth by setting the ``restapi.authentication.username`` and ``restapi.authentication.password`` parameters. There is no way to protect the safe endpoints without enabling TLS.
When TLS for the REST API is enabled and a PKI is established, mutual authentication of the API server and API client is possible for all endpoints.
The ``restapi`` section parameters enable TLS client authentication to the server. Depending on the value of the ``verify_client`` parameter, the API server requires a successful client certificate verification for both safe and unsafe API calls (``verify_client: required``), or only for unsafe API calls (``verify_client: optional``), or for no API calls (``verify_client: none``).
The ``ctl`` section parameters enable TLS server authentication to the client (the ``patronictl`` tool which uses the same config as patroni). Set ``insecure: true`` to disable the server certificate verification by the client. See :ref:`SETTINGS <patronictl_settings>` for a detailed description of the TLS client parameters.
Protecting the PostgreSQL database proper from unauthorized access is beyond the scope of this document and is covered in https://www.postgresql.org/docs/current/client-authentication.html
-21
View File
@@ -28,27 +28,6 @@ Feature: basic replication
When I issue a GET request to http://127.0.0.1:8009/async
Then I receive a response code 200
Scenario: check multi sync replication
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"synchronous_node_count": 2}
Then I receive a response code 200
And I sleep for 10 seconds
Then "sync" key in DCS has sync_standby=postgres1,postgres2 after 5 seconds
When I issue a GET request to http://127.0.0.1:8010/sync
Then I receive a response code 200
When I issue a GET request to http://127.0.0.1:8009/sync
Then I receive a response code 200
When I issue a PATCH request to http://127.0.0.1:8008/config with {"synchronous_node_count": 1}
Then I receive a response code 200
And I shut down postgres1
And I sleep for 10 seconds
Then "sync" key in DCS has sync_standby=postgres2 after 10 seconds
When I start postgres1
And "members/postgres1" key in DCS has state=running after 10 seconds
When I issue a GET request to http://127.0.0.1:8010/sync
Then I receive a response code 200
When I issue a GET request to http://127.0.0.1:8009/async
Then I receive a response code 200
Scenario: check the basic failover in synchronous mode
Given I run patronictl.py pause batman
Then I receive a response returncode 0
+13 -89
View File
@@ -165,11 +165,6 @@ class PatroniController(AbstractController):
config = yaml.safe_load(f)
config.pop('etcd', None)
raft_port = os.environ.get('RAFT_PORT')
if raft_port:
os.environ['RAFT_PORT'] = str(int(raft_port) + 1)
config['raft'] = {'data_dir': self._output_dir, 'self_addr': 'localhost:' + os.environ['RAFT_PORT']}
host = config['postgresql']['listen'].split(':')[0]
config['postgresql']['listen'] = config['postgresql']['connect_address'] = '{0}:{1}'.format(host, self.__PORT)
@@ -378,36 +373,21 @@ class ConsulController(AbstractDcsController):
super(ConsulController, self).start(max_wait_limit)
class AbstractEtcdController(AbstractDcsController):
class EtcdController(AbstractDcsController):
""" handles all etcd related tasks, used for the tests setup and cleanup """
def __init__(self, context, client_cls):
super(AbstractEtcdController, self).__init__(context)
self._client_cls = client_cls
def __init__(self, context):
super(EtcdController, self).__init__(context)
os.environ['PATRONI_ETCD_HOST'] = 'localhost:2379'
import etcd
self._client = etcd.Client(port=2379)
def _start(self):
return subprocess.Popen(["etcd", "--debug", "--data-dir", self._work_directory],
stdout=self._log, stderr=subprocess.STDOUT)
def _is_running(self):
from patroni.dcs.etcd import DnsCachingResolver
# if etcd is running, but we didn't start it
try:
self._client = self._client_cls({'host': 'localhost', 'port': 2379, 'retry_timeout': 30,
'patronictl': 1}, DnsCachingResolver())
return True
except Exception:
return False
class EtcdController(AbstractEtcdController):
def __init__(self, context):
from patroni.dcs.etcd import EtcdClient
super(EtcdController, self).__init__(context, EtcdClient)
os.environ['PATRONI_ETCD_HOST'] = 'localhost:2379'
def query(self, key, scope='batman'):
import etcd
try:
@@ -424,25 +404,12 @@ class EtcdController(AbstractEtcdController):
except Exception as e:
assert False, "exception when cleaning up etcd contents: {0}".format(e)
class Etcd3Controller(AbstractEtcdController):
def __init__(self, context):
from patroni.dcs.etcd3 import Etcd3Client
super(Etcd3Controller, self).__init__(context, Etcd3Client)
os.environ['PATRONI_ETCD3_HOST'] = 'localhost:2379'
def query(self, key, scope='batman'):
import base64
response = self._client.range(self.path(key, scope))
for k in response.get('kvs', []):
return base64.b64decode(k['value']).decode('utf-8') if 'value' in k else None
def cleanup_service_tree(self):
def _is_running(self):
# if etcd is running, but we didn't start it
try:
self._client.deleteprefix(self.path(scope=''))
except Exception as e:
assert False, "exception when cleaning up etcd contents: {0}".format(e)
return bool(self._client.machines)
except Exception:
return False
class KubernetesController(AbstractDcsController):
@@ -454,9 +421,8 @@ class KubernetesController(AbstractDcsController):
self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
os.environ['PATRONI_KUBERNETES_LABELS'] = json.dumps(self._labels)
os.environ['PATRONI_KUBERNETES_USE_ENDPOINTS'] = 'true'
os.environ['PATRONI_KUBERNETES_BYPASS_API_SERVICE'] = 'true'
from patroni.dcs.kubernetes import k8s_client, k8s_config
from kubernetes import client as k8s_client, config as k8s_config
k8s_config.load_kube_config(context='local')
self._client = k8s_client
self._api = self._client.CoreV1Api()
@@ -565,48 +531,6 @@ class ExhibitorController(ZooKeeperController):
os.environ.update({'PATRONI_EXHIBITOR_HOSTS': 'localhost', 'PATRONI_EXHIBITOR_PORT': '8181'})
class RaftController(AbstractDcsController):
CONTROLLER_ADDR = 'localhost:1234'
def __init__(self, context):
super(RaftController, self).__init__(context)
os.environ.update(PATRONI_RAFT_PARTNER_ADDRS="'" + self.CONTROLLER_ADDR + "'", RAFT_PORT='1234')
self._raft = None
def _start(self):
env = os.environ.copy()
del env['PATRONI_RAFT_PARTNER_ADDRS']
env['PATRONI_RAFT_SELF_ADDR'] = self.CONTROLLER_ADDR
env['PATRONI_RAFT_DATA_DIR'] = self._work_directory
return subprocess.Popen([sys.executable, '-m', 'coverage', 'run',
'--source=patroni', '-p', 'patroni_raft_controller.py'],
stdout=self._log, stderr=subprocess.STDOUT, env=env)
def query(self, key, scope='batman'):
ret = self._raft.get(self.path(key, scope))
return ret and ret['value']
def set(self, key, value):
self._raft.set(self.path(key), value)
def cleanup_service_tree(self):
from patroni.dcs.raft import KVStoreTTL
from pysyncobj import SyncObjConf
if self._raft:
self._raft.destroy()
self._raft._SyncObj__thread.join()
self.stop()
os.makedirs(self._work_directory)
self.start()
ready_event = threading.Event()
conf = SyncObjConf(appendEntriesUseBatch=False, dynamicMembershipChange=True, onReady=ready_event.set)
self._raft = KVStoreTTL(None, [self.CONTROLLER_ADDR], conf)
ready_event.wait()
class PatroniPoolController(object):
BACKUP_SCRIPT = [sys.executable, 'features/backup_create.py']
-2
View File
@@ -25,7 +25,6 @@ Feature: standby cluster
Then postgres1 is a leader of batman1 after 10 seconds
When I add the table foo to postgres0
Then table foo is present on postgres1 after 20 seconds
And I sleep for 3 seconds
When I issue a GET request to http://127.0.0.1:8009/master
Then I receive a response code 503
When I issue a GET request to http://127.0.0.1:8009/standby_leader
@@ -42,7 +41,6 @@ Feature: standby cluster
Then postgres2 is replicating from postgres0 after 32 seconds
When I issue a GET request to http://127.0.0.1:8010/master
Then I receive a response code 503
And I sleep for 3 seconds
When I issue a GET request to http://127.0.0.1:8010/standby_leader
Then I receive a response code 200
And I receive a response role standby_leader
+1 -1
View File
@@ -21,7 +21,7 @@ def write_label(context, content, name):
context.pctl.write_label(name, content)
@step('"{name}" key in DCS has {key:w}={value} after {time_limit:d} seconds')
@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)
+3 -1
View File
@@ -33,5 +33,7 @@ postgresql:
__EOF__
unset PATRONI_SUPERUSER_PASSWORD PATRONI_REPLICATION_PASSWORD
export KUBERNETES_NAMESPACE=$PATRONI_KUBERNETES_NAMESPACE
export POD_NAME=$PATRONI_NAME
exec /usr/bin/python3 /usr/local/bin/patroni /home/postgres/patroni.yml
exec /usr/bin/python3 /usr/local/bin/patroni /home/postgres/patroni.yml
@@ -118,8 +118,6 @@ objects:
fieldRef:
apiVersion: v1
fieldPath: metadata.namespace
- name: PATRONI_KUBERNETES_BYPASS_API_SERVICE
value: 'true'
- name: PATRONI_KUBERNETES_LABELS
value: '{application: ${APPLICATION_NAME}, cluster-name: ${PATRONI_CLUSTER_NAME}}'
- name: PATRONI_SUPERUSER_USERNAME
@@ -154,16 +152,6 @@ objects:
image: docker-registry.default.svc:5000/${NAMESPACE}/patroni:latest
imagePullPolicy: IfNotPresent
name: ${APPLICATION_NAME}
readinessProbe:
httpGet:
scheme: HTTP
path: /readiness
port: 8008
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 5
successThreshold: 1
failureThreshold: 3
ports:
- containerPort: 8008
protocol: TCP
@@ -252,34 +240,6 @@ objects:
subjects:
- kind: ServiceAccount
name: ${SERVICE_ACCOUNT}
# Following privileges are only required if deployed not in the "default"
# namespace and you want Patroni to bypass kubernetes service
# (PATRONI_KUBERNETES_BYPASS_API_SERVICE=true)
- apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: patroni-k8s-ep-access
rules:
- apiGroups:
- ""
resources:
- endpoints
resourceNames:
- kubernetes
verbs:
- get
- apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: ${NAMESPACE}-${SERVICE_ACCOUNT}-k8s-ep-access
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: patroni-k8s-ep-access
subjects:
- kind: ServiceAccount
name: ${SERVICE_ACCOUNT}
namespace: ${NAMESPACE}
parameters:
- description: The name of the application for labelling all artifacts.
displayName: Application Name
@@ -5,9 +5,11 @@ metadata:
annotations:
description: |-
Patroni Postgresql database cluster, with persistent storage.
WARNING: Any data stored will be lost upon pod destruction. Only use this template for testing.
iconClass: icon-postgresql
openshift.io/display-name: Patroni Postgresql (Persistent)
openshift.io/long-description: This template deploys a a patroni postgresql HA cluster with persistent storage.
openshift.io/long-description: This template deploys a a patroni postgresql HA cluster without persistent storage.
tags: postgresql
objects:
- apiVersion: v1
@@ -130,8 +132,6 @@ objects:
fieldRef:
apiVersion: v1
fieldPath: metadata.namespace
- name: PATRONI_KUBERNETES_BYPASS_API_SERVICE
value: 'true'
- name: PATRONI_KUBERNETES_LABELS
value: '{application: ${APPLICATION_NAME}, cluster-name: ${PATRONI_CLUSTER_NAME}}'
- name: PATRONI_SUPERUSER_USERNAME
@@ -166,16 +166,6 @@ objects:
image: docker-registry.default.svc:5000/${NAMESPACE}/patroni:latest
imagePullPolicy: IfNotPresent
name: ${APPLICATION_NAME}
readinessProbe:
httpGet:
scheme: HTTP
path: /readiness
port: 8008
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 5
successThreshold: 1
failureThreshold: 3
ports:
- containerPort: 8008
protocol: TCP
@@ -276,34 +266,6 @@ objects:
subjects:
- kind: ServiceAccount
name: ${SERVICE_ACCOUNT}
# Following privileges are only required if deployed not in the "default"
# namespace and you want Patroni to bypass kubernetes service
# (PATRONI_KUBERNETES_BYPASS_API_SERVICE=true)
- apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: patroni-k8s-ep-access
rules:
- apiGroups:
- ""
resources:
- endpoints
resourceNames:
- kubernetes
verbs:
- get
- apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: ${NAMESPACE}-${SERVICE_ACCOUNT}-k8s-ep-access
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: patroni-k8s-ep-access
subjects:
- kind: ServiceAccount
name: ${SERVICE_ACCOUNT}
namespace: ${NAMESPACE}
parameters:
- description: The name of the application for labelling all artifacts.
displayName: Application Name
@@ -352,4 +314,4 @@ parameters:
- description: The size of the persistent volume to create.
displayName: Persistent Volume Size
name: PVC_SIZE
value: 5Gi
value: 5Gi
+1 -66
View File
@@ -10,7 +10,7 @@ spec:
clusterIP: None
---
apiVersion: apps/v1
apiVersion: apps/v1beta1
kind: StatefulSet
metadata:
name: &cluster_name patronidemo
@@ -31,16 +31,6 @@ spec:
- name: *cluster_name
image: patroni # docker build -t patroni .
imagePullPolicy: IfNotPresent
readinessProbe:
httpGet:
scheme: HTTP
path: /readiness
port: 8008
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 5
successThreshold: 1
failureThreshold: 3
ports:
- containerPort: 8008
protocol: TCP
@@ -58,8 +48,6 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: PATRONI_KUBERNETES_BYPASS_API_SERVICE
value: 'true'
- name: PATRONI_KUBERNETES_USE_ENDPOINTS
value: 'true'
- name: PATRONI_KUBERNETES_LABELS
@@ -135,25 +123,6 @@ spec:
- port: 5432
targetPort: 5432
---
apiVersion: v1
kind: Service
metadata:
name: patronidemo-repl
labels:
application: patroni
cluster-name: &cluster_name patronidemo
role: replica
spec:
type: ClusterIP
selector:
application: patroni
cluster-name: *cluster_name
role: replica
ports:
- port: 5432
targetPort: 5432
---
apiVersion: v1
kind: Secret
@@ -241,37 +210,3 @@ roleRef:
subjects:
- kind: ServiceAccount
name: patronidemo
# Following privileges are only required if deployed not in the "default"
# namespace and you want Patroni to bypass kubernetes service
# (PATRONI_KUBERNETES_BYPASS_API_SERVICE=true)
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: patroni-k8s-ep-access
rules:
- apiGroups:
- ""
resources:
- endpoints
resourceNames:
- kubernetes
verbs:
- get
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: patroni-k8s-ep-access
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: patroni-k8s-ep-access
subjects:
- kind: ServiceAccount
name: patronidemo
# The namespace must be specified explicitly.
# If deploying to the different namespace you have to change it.
namespace: default
+87 -22
View File
@@ -4,8 +4,7 @@ import signal
import sys
import time
from .daemon import AbstractPatroniDaemon, abstract_main
from .version import __version__
from patroni.version import __version__
logger = logging.getLogger(__name__)
@@ -13,19 +12,23 @@ PATRONI_ENV_PREFIX = 'PATRONI_'
KUBERNETES_ENV_PREFIX = 'KUBERNETES_'
class Patroni(AbstractPatroniDaemon):
class Patroni(object):
def __init__(self, config):
def __init__(self, conf):
from patroni.api import RestApiServer
from patroni.dcs import get_dcs
from patroni.ha import Ha
from patroni.log import PatroniLogger
from patroni.postgresql import Postgresql
from patroni.request import PatroniRequest
from patroni.watchdog import Watchdog
super(Patroni, self).__init__(config)
self.setup_signal_handlers()
self.version = __version__
self.logger = PatroniLogger()
self.config = conf
self.logger.reload_config(self.config.get('log', {}))
self.dcs = get_dcs(self.config)
self.watchdog = Watchdog(self.config)
self.load_dynamic_configuration()
@@ -68,14 +71,14 @@ class Patroni(AbstractPatroniDaemon):
def nosync(self):
return bool(self.tags.get('nosync', False))
def reload_config(self, sighup=False, local=False):
def reload_config(self, sighup=False):
try:
super(Patroni, self).reload_config(sighup, local)
if local:
self.tags = self.get_tags()
self.request.reload_config(self.config)
self.api.reload_config(self.config['restapi'])
self.tags = self.get_tags()
self.logger.reload_config(self.config.get('log', {}))
self.watchdog.reload_config(self.config)
if sighup:
self.request.reload_config(self.config)
self.api.reload_config(self.config['restapi'])
self.postgresql.reload_config(self.config['postgresql'], sighup)
self.dcs.reload_config(self.config)
except Exception:
@@ -85,6 +88,15 @@ class Patroni(AbstractPatroniDaemon):
def replicatefrom(self):
return self.tags.get('replicatefrom')
def sighup_handler(self, *args):
self._received_sighup = True
def sigterm_handler(self, *args):
with self._sigterm_lock:
if not self._received_sigterm:
self._received_sigterm = True
sys.exit()
@property
def noloadbalance(self):
return bool(self.tags.get('noloadbalance', False))
@@ -102,24 +114,48 @@ class Patroni(AbstractPatroniDaemon):
elif self.ha.watch(nap_time):
self.next_run = time.time()
@property
def received_sigterm(self):
with self._sigterm_lock:
return self._received_sigterm
def run(self):
self.api.start()
self.logger.start()
self.next_run = time.time()
super(Patroni, self).run()
def _run_cycle(self):
logger.info(self.ha.run_cycle())
while not self.received_sigterm:
if self._received_sighup:
self._received_sighup = False
if self.config.reload_local_configuration():
self.reload_config(True)
else:
self.postgresql.config.reload_config(self.config['postgresql'], True)
if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \
and self.config.set_dynamic_configuration(self.dcs.cluster.config):
self.reload_config()
logger.info(self.ha.run_cycle())
if self.postgresql.role != 'uninitialized':
self.config.save_cache()
if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \
and self.config.set_dynamic_configuration(self.dcs.cluster.config):
self.reload_config()
self.schedule_next_run()
if self.postgresql.role != 'uninitialized':
self.config.save_cache()
def _shutdown(self):
self.schedule_next_run()
def setup_signal_handlers(self):
from threading import Lock
self._received_sighup = False
self._sigterm_lock = Lock()
self._received_sigterm = False
if os.name != 'nt':
signal.signal(signal.SIGHUP, self.sighup_handler)
signal.signal(signal.SIGTERM, self.sigterm_handler)
def shutdown(self):
with self._sigterm_lock:
self._received_sigterm = True
try:
self.api.shutdown()
except Exception:
@@ -128,14 +164,43 @@ class Patroni(AbstractPatroniDaemon):
self.ha.shutdown()
except Exception:
logger.exception('Exception during Ha.shutdown')
self.logger.shutdown()
def patroni_main():
import argparse
from multiprocessing import freeze_support
from patroni.config import Config, ConfigParseError
from patroni.validator import schema
freeze_support()
abstract_main(Patroni, schema)
parser = argparse.ArgumentParser()
parser.add_argument('--version', action='version', version='%(prog)s {0}'.format(__version__))
parser.add_argument('--validate-config', action='store_true', help='Run config validator and exit')
parser.add_argument('configfile', nargs='?', default='',
help='Patroni may also read the configuration from the {0} environment variable'
.format(Config.PATRONI_CONFIG_VARIABLE))
args = parser.parse_args()
try:
if args.validate_config:
conf = Config(args.configfile, validator=schema)
sys.exit()
else:
conf = Config(args.configfile)
except ConfigParseError as e:
if e.value:
print(e.value)
parser.print_help()
sys.exit(1)
patroni = Patroni(conf)
try:
patroni.run()
except KeyboardInterrupt:
pass
finally:
patroni.shutdown()
def fatal(string, *args):
+18 -55
View File
@@ -9,28 +9,20 @@ import datetime
import os
import six
import socket
import sys
from patroni.exceptions import PostgresConnectionException, PostgresException
from patroni.postgresql.misc import postgres_version_to_int
from patroni.utils import deep_compare, parse_bool, patch_config, Retry, \
RetryFailedError, parse_int, split_host_port, tzutc, uri, cluster_as_json
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
from six.moves.socketserver import ThreadingMixIn
from six.moves.urllib_parse import urlparse, parse_qs
from threading import Thread
from .exceptions import PostgresConnectionException, PostgresException
from .postgresql.misc import postgres_version_to_int
from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Retry, \
RetryFailedError, parse_int, split_host_port, tzutc, uri, cluster_as_json
logger = logging.getLogger(__name__)
class RestApiHandler(BaseHTTPRequestHandler):
def _write_status_code_only(self, status_code):
message = self.responses[status_code][0]
self.wfile.write('{0} {1} {2}\r\n\r\n'.format(self.protocol_version, status_code, message).encode('utf-8'))
self.log_request(status_code)
def _write_response(self, status_code, body, content_type='text/html', headers=None):
self.send_response(status_code)
headers = headers or {}
@@ -38,8 +30,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
headers['Content-Type'] = content_type
for name, value in headers.items():
self.send_header(name, value)
for name, value in self.server.http_extra_headers.items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(body.encode('utf-8'))
@@ -90,20 +80,16 @@ class RestApiHandler(BaseHTTPRequestHandler):
def do_GET(self, write_status_code_only=False):
"""Default method for processing all GET requests which can not be routed to other methods"""
time_start = time.time()
request_type = 'OPTIONS' if write_status_code_only else 'GET'
path = '/master' if self.path == '/' else self.path
response = self.get_postgresql_status()
patroni = self.server.patroni
cluster = patroni.dcs.cluster
leader_optime = cluster and cluster.last_leader_operation or 0
replayed_location = response.get('xlog', {}).get('replayed_location', 0)
max_replica_lag = parse_int(self.path_query.get('lag', [sys.maxsize])[0], 'B')
if max_replica_lag is None:
max_replica_lag = sys.maxsize
is_lagging = leader_optime and leader_optime > replayed_location + max_replica_lag
replica_status_code = 200 if not patroni.noloadbalance and not is_lagging and \
replica_status_code = 200 if not patroni.noloadbalance and \
response.get('role') == 'replica' and response.get('state') == 'running' else 503
if not cluster and patroni.ha.is_paused():
@@ -133,33 +119,25 @@ class RestApiHandler(BaseHTTPRequestHandler):
status_code = 200 if response.get('state') == 'running' else 503
elif cluster: # dcs is available
is_synchronous = cluster.is_synchronous_mode() and cluster.sync \
and patroni.postgresql.name in cluster.sync.members
and cluster.sync.sync_standby == patroni.postgresql.name
if path in ('/sync', '/synchronous') and is_synchronous:
status_code = replica_status_code
elif path in ('/async', '/asynchronous') and not is_synchronous:
status_code = replica_status_code
if write_status_code_only: # when haproxy sends OPTIONS request it reads only status code and nothing more
self._write_status_code_only(status_code)
message = self.responses[status_code][0]
self.wfile.write('{0} {1} {2}\r\n\r\n'.format(self.protocol_version, status_code, message).encode('utf-8'))
else:
self._write_status_response(status_code, response)
time_end = time.time()
self.log_message('%s %s %s latency: %s ms', request_type, path,
status_code, (time_end - time_start) * 1000)
def do_OPTIONS(self):
self.do_GET(write_status_code_only=True)
def do_GET_liveness(self):
self._write_status_code_only(200)
def do_GET_readiness(self):
patroni = self.server.patroni
if patroni.ha.is_leader():
status_code = 200
elif patroni.postgresql.state == 'running':
status_code = 200 if patroni.dcs.cluster else 503
else:
status_code = 503
self._write_status_code_only(status_code)
def do_GET_patroni(self):
response = self.get_postgresql_status(True)
self._write_status_response(200, response)
@@ -198,8 +176,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
request = self._read_json_content()
if request:
cluster = self.server.patroni.dcs.get_cluster()
if not (cluster.config and cluster.config.modify_index):
return self.send_error(503)
data = cluster.config.data.copy()
if patch_config(data, request):
value = json.dumps(data, separators=(',', ':'))
@@ -369,13 +345,13 @@ class RestApiHandler(BaseHTTPRequestHandler):
if leader and (not cluster.leader or cluster.leader.name != leader):
return 'leader name does not match'
if candidate:
if action == 'switchover' and cluster.is_synchronous_mode() and candidate not in cluster.sync.members:
if action == 'switchover' and cluster.is_synchronous_mode() and cluster.sync.sync_standby != candidate:
return 'candidate name does not match with sync_standby'
members = [m for m in cluster.members if m.name == candidate]
if not members:
return 'candidate does not exists'
elif cluster.is_synchronous_mode():
members = [m for m in cluster.members if m.name in cluster.sync.members]
members = [m for m in cluster.members if m.name == cluster.sync.sync_standby]
if not members:
return action + ' is not possible: can not find sync_standby'
else:
@@ -452,9 +428,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
ret = BaseHTTPRequestHandler.parse_request(self)
if ret:
urlpath = urlparse(self.path)
self.path = urlpath.path
self.path_query = parse_qs(urlpath.query) or {}
mname = self.path.lstrip('/').split('/')[0]
mname = self.command + ('_' + mname if mname else '')
if hasattr(self, 'do_' + mname):
@@ -518,13 +491,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
state = 'unknown'
return {'state': state, 'role': postgresql.role}
def handle_one_request(self):
self.__start_time = time.time()
BaseHTTPRequestHandler.handle_one_request(self)
def log_message(self, fmt, *args):
latency = 1000.0 * (time.time() - self.__start_time)
logger.debug("API thread: %s - - %s latency: %0.3f ms", self.client_address[0], fmt % args, latency)
logger.debug("API thread: %s - - [%s] %s", self.client_address[0], self.log_date_time_string(), fmt % args)
class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
@@ -535,7 +503,6 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
self.patroni = patroni
self.__listen = None
self.__ssl_options = None
self.http_extra_headers = {}
self.reload_config(config)
self.daemon = True
@@ -659,7 +626,6 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
def get_request(self):
sock = self.socket
newsock, addr = socket.socket.accept(sock)
enable_keepalive(newsock, 10, 3)
if hasattr(sock, 'context'): # SSLSocket, we want to do the deferred handshake from a thread
newsock = (sock, newsock)
return newsock, addr
@@ -670,9 +636,6 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
ssl_options = {n: config[n] for n in ('certfile', 'keyfile', 'cafile') if n in config}
self.http_extra_headers = config.get('http_extra_headers') or {}
self.http_extra_headers.update((config.get('https_extra_headers') or {}) if ssl_options.get('certfile') else {})
if isinstance(config.get('verify_client'), six.string_types):
ssl_options['verify_client'] = config['verify_client'].lower()
+11 -41
View File
@@ -64,7 +64,6 @@ class Config(object):
'master_stop_timeout': 0,
'synchronous_mode': False,
'synchronous_mode_strict': False,
'synchronous_node_count': 1,
'standby_cluster': {
'create_replica_methods': '',
'host': '',
@@ -77,8 +76,7 @@ class Config(object):
'postgresql': {
'bin_dir': '',
'use_slots': True,
'parameters': CaseInsensitiveDict({p: v[0] for p, v in ConfigHandler.CMDLINE_OPTIONS.items()
if p not in ('wal_keep_segments', 'wal_keep_size')})
'parameters': CaseInsensitiveDict({p: v[0] for p, v in ConfigHandler.CMDLINE_OPTIONS.items()})
},
'watchdog': {
'mode': 'automatic',
@@ -92,7 +90,7 @@ class Config(object):
self.__environment_configuration = self._build_environment_configuration()
# Patroni reads the configuration from the command-line argument if it exists, otherwise from the environment
self._config_file = configfile and os.path.exists(configfile) and configfile
self._config_file = configfile and os.path.isfile(configfile) and configfile
if self._config_file:
self._local_configuration = self._load_config_file()
else:
@@ -120,32 +118,12 @@ class Config(object):
def check_mode(self, mode):
return bool(parse_bool(self._dynamic_configuration.get(mode)))
def _load_config_path(self, path):
"""
If path is a file, loads the yml file pointed to by path.
If path is a directory, loads all yml files in that directory in alphabetical order
"""
if os.path.isfile(path):
files = [path]
elif os.path.isdir(path):
files = [os.path.join(path, f) for f in sorted(os.listdir(path))
if (f.endswith('.yml') or f.endswith('.yaml')) and os.path.isfile(os.path.join(path, f))]
else:
logger.error('config path %s is neither directory nor file', path)
raise ConfigParseError('invalid config path')
overall_config = {}
for fname in files:
with open(fname) as f:
config = yaml.safe_load(f)
patch_config(overall_config, config)
return overall_config
def _load_config_file(self):
"""Loads config.yaml from filesystem and applies some values which were set via ENV"""
config = self._load_config_path(self._config_file)
patch_config(config, self.__environment_configuration)
return config
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):
@@ -264,8 +242,7 @@ class Config(object):
if value:
ret[section][param] = value
_set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'cafile', 'verify_client',
'http_extra_headers', 'https_extra_headers'])
_set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'cafile', 'verify_client'])
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile'])
_set_section_values('postgresql', ['listen', 'connect_address', 'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
_set_section_values('log', ['level', 'traceback_level', 'format', 'dateformat', 'max_queue_size',
@@ -316,10 +293,6 @@ class Config(object):
logger.exception('Exception when parsing list %s', value)
return None
_set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs'])
if 'raft' in ret and 'partner_addrs' in ret['raft']:
ret['raft']['partner_addrs'] = _parse_list(ret['raft']['partner_addrs'])
for param in list(os.environ.keys()):
if param.startswith(PATRONI_ENV_PREFIX):
# PATRONI_(ETCD|CONSUL|ZOOKEEPER|EXHIBITOR|...)_(HOSTS?|PORT|..)
@@ -327,8 +300,7 @@ class Config(object):
if suffix in ('HOST', 'HOSTS', 'PORT', 'USE_PROXIES', 'PROTOCOL', 'SRV', 'URL', 'PROXY',
'CACERT', 'CERT', 'KEY', 'VERIFY', 'TOKEN', 'CHECKS', 'DC', 'CONSISTENCY',
'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'NAMESPACE', 'CONTEXT',
'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL', 'POD_IP', 'PORTS', 'LABELS',
'BYPASS_API_SERVICE', 'KEY_PASSWORD', 'USE_SSL') and name:
'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL', 'POD_IP', 'PORTS', 'LABELS') and name:
value = os.environ.pop(param)
if suffix == 'PORT':
value = value and parse_int(value)
@@ -336,13 +308,12 @@ class Config(object):
value = value and _parse_list(value)
elif suffix == 'LABELS':
value = _parse_dict(value)
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'BYPASS_API_SERVICE'):
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE'):
value = parse_bool(value)
if value:
ret[name.lower()][suffix.lower()] = value
for dcs in ('etcd', 'etcd3'):
if dcs in ret:
ret[dcs].update(_get_auth(dcs))
if 'etcd' in ret:
ret['etcd'].update(_get_auth('etcd'))
users = {}
for param in list(os.environ.keys()):
@@ -410,7 +381,6 @@ class Config(object):
'retry_timeout',
'synchronous_mode',
'synchronous_mode_strict',
'synchronous_node_count',
)
pg_config.update({p: config[p] for p in updated_fields if p in config})
+41 -26
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
'''
Patroni Control
'''
@@ -32,8 +33,8 @@ from patroni.postgresql.misc import postgres_version_to_int
from patroni.utils import cluster_as_json, patch_config, polling_loop
from patroni.request import PatroniRequest
from patroni.version import __version__
from prettytable import ALL, FRAME, PrettyTable
from six.moves.urllib_parse import urlparse
from texttable import Texttable
CONFIG_DIR_PATH = click.get_app_dir('patroni')
CONFIG_FILE_PATH = os.path.join(CONFIG_DIR_PATH, 'patronictl.yaml')
@@ -47,32 +48,42 @@ class PatroniCtlException(ClickException):
pass
class PatronictlPrettyTable(PrettyTable):
class PatronictlPrettyTable(Texttable):
def __init__(self, header, *args, **kwargs):
PrettyTable.__init__(self, *args, **kwargs)
def __init__(self, header=None):
Texttable.__init__(self, 0)
self.__table_header = header
self.__hline_num = 0
self.__hline = None
if sys.platform != 'win32':
self._char_horiz = u''
self._char_vert = u''
self._hline_header = self._hline
self._char_header = self._char_horiz
def _is_first_hline(self):
return self.__hline_num == 0
def _set_hline(self, value):
self.__hline = value
def _is_last_hline(self):
return self.__hline_num > (len(self._rows) if self._has_hlines() else int(bool(self._header)))
def _get_hline(self):
ret = self.__hline
def _hline(self):
if sys.platform == 'win32':
left = right = self._char_corner
elif self._is_first_hline():
left, self._char_corner, right = u'', u'', u''
elif not self._is_last_hline():
left, self._char_corner, right = u'', u'', u''
else:
left, self._char_corner, right = u'', u'', u''
line = self._build_hline()
# Inject nice table header
if self._is_first_hline() and self.__table_header:
header = self.__table_header[:len(ret) - 2]
ret = "".join([ret[0], header, ret[1 + len(header):]])
left += self.__table_header
self.__hline_num += 1
return ret
_hrule = property(_get_hline, _set_hline)
return left + line[len(left):-2] + right + '\n'
def parse_dcs(dcs):
@@ -186,14 +197,17 @@ def print_output(columns, rows, alignment=None, fmt='pretty', header=None, delim
for r in ([columns] if columns else []) + rows:
click.echo(delimiter.join(map(str, r)))
else:
hrules = ALL if any(any(isinstance(c, six.string_types) and '\n' in c for c in r) for r in rows) else FRAME
table = PatronictlPrettyTable(header, columns, hrules=hrules)
table.align = 'l'
for k, v in (alignment or {}).items():
table.align[k] = v
for r in rows:
table.add_row(r)
click.echo(table)
table = PatronictlPrettyTable(header)
if not any(any(isinstance(c, six.string_types) and '\n' in c for c in r) for r in rows):
table.set_deco(Texttable.VLINES | Texttable.BORDER | Texttable.HEADER)
if rows:
if columns:
table.header(columns)
table.set_cols_align([(alignment or {}).get(c, 'l') for c in columns])
table.add_rows(rows, header=False)
else:
table.add_rows([columns], header=False)
click.echo(table.draw())
def watching(w, watch, max_count=None, clear=True):
@@ -226,7 +240,7 @@ def watching(w, watch, max_count=None, clear=True):
def get_all_members(cluster, role='master'):
if role == 'master':
if cluster.leader is not None and cluster.leader.name:
if cluster.leader is not None:
yield cluster.leader
return
@@ -641,7 +655,7 @@ def _do_failover_or_switchover(obj, action, cluster_name, master, candidate, for
dcs = get_dcs(obj, cluster_name)
cluster = dcs.get_cluster()
if action == 'switchover' and (cluster.leader is None or not cluster.leader.name):
if action == 'switchover' and cluster.leader is None:
raise PatroniCtlException('This cluster has no master')
if master is None:
@@ -821,7 +835,8 @@ def output_members(cluster, name, extended=False, fmt='pretty'):
rows.append([m.get(n.lower().replace(' ', '_'), '') for n in columns])
print_output(columns, rows, {'Lag in MB': 'r', 'TL': 'r'}, fmt, ' Cluster: {0} ({1}) '.format(name, initialize))
print_output(columns, rows, {'Member': 'l', 'Lag in MB': 'r', 'TL': 'r', 'Tags': 'l'},
fmt, ' Cluster: {0} ({1}) '.format(name, initialize))
if fmt not in ('pretty', 'topology'): # Omit service info when using machine-readable formats
return
-104
View File
@@ -1,104 +0,0 @@
import abc
import os
import signal
import six
import sys
from threading import Lock
@six.add_metaclass(abc.ABCMeta)
class AbstractPatroniDaemon(object):
def __init__(self, config):
from patroni.log import PatroniLogger
self.setup_signal_handlers()
self.logger = PatroniLogger()
self.config = config
AbstractPatroniDaemon.reload_config(self, local=True)
def sighup_handler(self, *args):
self._received_sighup = True
def sigterm_handler(self, *args):
with self._sigterm_lock:
if not self._received_sigterm:
self._received_sigterm = True
sys.exit()
def setup_signal_handlers(self):
self._received_sighup = False
self._sigterm_lock = Lock()
self._received_sigterm = False
if os.name != 'nt':
signal.signal(signal.SIGHUP, self.sighup_handler)
signal.signal(signal.SIGTERM, self.sigterm_handler)
@property
def received_sigterm(self):
with self._sigterm_lock:
return self._received_sigterm
def reload_config(self, sighup=False, local=False):
if local:
self.logger.reload_config(self.config.get('log', {}))
@abc.abstractmethod
def _run_cycle(self):
"""_run_cycle"""
def run(self):
self.logger.start()
while not self.received_sigterm:
if self._received_sighup:
self._received_sighup = False
self.reload_config(True, self.config.reload_local_configuration())
self._run_cycle()
@abc.abstractmethod
def _shutdown(self):
"""_shutdown"""
def shutdown(self):
with self._sigterm_lock:
self._received_sigterm = True
self._shutdown()
self.logger.shutdown()
def abstract_main(cls, validator=None):
import argparse
from .config import Config, ConfigParseError
from .version import __version__
parser = argparse.ArgumentParser()
parser.add_argument('--version', action='version', version='%(prog)s {0}'.format(__version__))
if validator:
parser.add_argument('--validate-config', action='store_true', help='Run config validator and exit')
parser.add_argument('configfile', nargs='?', default='',
help='Patroni may also read the configuration from the {0} environment variable'
.format(Config.PATRONI_CONFIG_VARIABLE))
args = parser.parse_args()
try:
if validator and args.validate_config:
Config(args.configfile, validator=validator)
sys.exit()
config = Config(args.configfile)
except ConfigParseError as e:
if e.value:
print(e.value)
parser.print_help()
sys.exit(1)
controller = cls(config)
try:
controller.run()
except KeyboardInterrupt:
pass
finally:
controller.shutdown()
+8 -18
View File
@@ -13,7 +13,7 @@ import time
from collections import defaultdict, namedtuple
from copy import deepcopy
from patroni.exceptions import PatroniFatalException
from patroni.exceptions import PatroniException
from patroni.utils import parse_bool, uri
from random import randint
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
@@ -102,7 +102,7 @@ def get_dcs(config):
and inspect.isclass(item) and issubclass(item, AbstractDCS))
except ImportError:
logger.info('Failed to import %s', module_name)
raise PatroniFatalException("""Can not find suitable configuration of distributed configuration store
raise PatroniException("""Can not find suitable configuration of distributed configuration store
Available implementations: """ + ', '.join(sorted(set(available_implementations))))
@@ -351,7 +351,7 @@ class SyncState(namedtuple('SyncState', 'index,leader,sync_standby')):
:param index: modification index of a synchronization key in a Configuration Store
:param leader: reference to member that was leader
:param sync_standby: synchronous standby list (comma delimited) which are last synchronized to leader
:param sync_standby: standby that was last synchronized to leader
"""
@staticmethod
@@ -383,22 +383,15 @@ class SyncState(namedtuple('SyncState', 'index,leader,sync_standby')):
data = {}
return SyncState(index, data.get('leader'), data.get('sync_standby'))
@property
def members(self):
""" Returns sync_standby in list """
return self.sync_standby and self.sync_standby.split(',') or []
def matches(self, name):
"""
Returns if a node name matches one of the nodes in the sync state
>>> s = SyncState(1, 'foo', 'bar,zoo')
>>> s = SyncState(1, 'foo', 'bar')
>>> s.matches('foo')
True
>>> s.matches('bar')
True
>>> s.matches('zoo')
True
>>> s.matches('baz')
False
>>> s.matches(None)
@@ -406,7 +399,7 @@ class SyncState(namedtuple('SyncState', 'index,leader,sync_standby')):
>>> SyncState(1, None, None).matches('foo')
False
"""
return name is not None and name in [self.leader] + self.members
return name is not None and name in (self.leader, self.sync_standby)
class TimelineHistory(namedtuple('TimelineHistory', 'index,value,lines')):
@@ -507,8 +500,7 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
value['type'] = 'logical' if value.get('database') and value.get('plugin') else 'physical'
if value['type'] == 'physical':
# Don't try to create permanent physical replication slot for yourself
if name != slot_name_from_member_name(my_name):
if name != my_name: # Don't try to create permanent physical replication slot for yourself
slots[name] = value
continue
elif value['type'] == 'logical' and value.get('database') and value.get('plugin'):
@@ -794,10 +786,8 @@ class AbstractDCS(object):
@staticmethod
def sync_state(leader, sync_standby):
"""Build sync_state dict
sync_standby dictionary key being kept for backward compatibility
"""
return {'leader': leader, 'sync_standby': sync_standby and ','.join(sorted(sync_standby)) or None}
"""Build sync_state dict"""
return {'leader': leader, 'sync_standby': sync_standby}
def write_sync_state(self, leader, sync_standby, index=None):
sync_value = self.sync_state(leader, sync_standby)
+1 -4
View File
@@ -219,7 +219,6 @@ class Consul(AbstractDCS):
self.__session_checks = config.get('checks', [])
self._register_service = config.get('register_service', False)
if self._register_service:
self._service_tags = config.get('service_tags', [])
self._service_name = service_name_from_scope_name(self._scope)
if self._scope != self._service_name:
logger.warning('Using %s as consul service name instead of scope name %s', self._service_name,
@@ -415,14 +414,12 @@ class Consul(AbstractDCS):
conn_parts = urlparse(data['conn_url'])
check = base.Check.http(api_parts.geturl(), self._service_check_interval,
deregister='{0}s'.format(self._client.http.ttl * 10))
tags = self._service_tags[:]
tags.append(role)
params = {
'service_id': '{0}/{1}'.format(self._scope, self._name),
'address': conn_parts.hostname,
'port': conn_parts.port,
'check': check,
'tags': tags
'tags': [role]
}
if state == 'stopped':
+50 -104
View File
@@ -1,5 +1,4 @@
from __future__ import absolute_import
import abc
import etcd
import json
import logging
@@ -83,8 +82,7 @@ class DnsCachingResolver(Thread):
return []
@six.add_metaclass(abc.ABCMeta)
class AbstractEtcdClientWithFailover(etcd.Client):
class Client(etcd.Client):
def __init__(self, config, dns_resolver, cache_ttl=300):
self._dns_resolver = dns_resolver
@@ -92,7 +90,7 @@ class AbstractEtcdClientWithFailover(etcd.Client):
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(AbstractEtcdClientWithFailover, self).__init__(read_timeout=config['retry_timeout'], **args)
super(Client, self).__init__(read_timeout=config['retry_timeout'], **args)
# For some reason python3-etcd on debian and ubuntu are not based on the latest version
# Workaround for the case when https://github.com/jplana/python-etcd/pull/196 is not applied
self.http.connection_pool_kw.pop('ssl_version', None)
@@ -132,16 +130,12 @@ class AbstractEtcdClientWithFailover(etcd.Client):
return etcd_nodes, per_node_timeout, per_node_retries - 1
def reload_config(self, config):
self.username = config.get('username')
self.password = config.get('password')
def _get_headers(self):
basic_auth = ':'.join((self.username, self.password)) if self.username and self.password else None
return urllib3.make_headers(basic_auth=basic_auth, user_agent=USER_AGENT)
def _prepare_common_parameters(self, etcd_nodes, timeout=None):
kwargs = {'headers': self._get_headers(), 'redirect': self.allow_redirect, 'preload_content': False}
def _build_request_parameters(self, etcd_nodes, timeout=None):
kwargs = {'headers': self._get_headers(), 'redirect': self.allow_redirect}
if timeout is not None:
kwargs.update(retries=0, timeout=timeout)
@@ -154,14 +148,6 @@ class AbstractEtcdClientWithFailover(etcd.Client):
def set_machines_cache_ttl(self, cache_ttl):
self._machines_cache_ttl = cache_ttl
@abc.abstractmethod
def _prepare_get_members(self, etcd_nodes):
"""returns: request parameters"""
@abc.abstractmethod
def _get_members(self, base_uri, **kwargs):
"""returns: list of clientURLs"""
@property
def machines_cache(self):
base_uri, cache = self._base_uri, self._machines_cache
@@ -180,11 +166,13 @@ class AbstractEtcdClientWithFailover(etcd.Client):
the original method was retrying 2 times with the `read_timeout` on each node."""
machines_cache = self.machines_cache
kwargs = self._prepare_get_members(len(machines_cache))
kwargs = self._build_request_parameters(len(machines_cache))
for base_uri in machines_cache:
try:
machines = list(self._get_members(base_uri, **kwargs))
response = self.http.request(self._MGET, base_uri + self.version_prefix + '/machines', **kwargs)
data = self._handle_server_response(response).data.decode('utf-8')
machines = [m.strip() for m in data.split(',') if m.strip()]
logger.debug("Retrieved list of machines: %s", machines)
if machines:
random.shuffle(machines)
@@ -200,15 +188,14 @@ class AbstractEtcdClientWithFailover(etcd.Client):
self._read_timeout = timeout
def _do_http_request(self, retry, machines_cache, request_executor, method, path, fields=None, **kwargs):
if fields is not None:
kwargs['fields'] = fields
some_request_failed = False
for i, base_uri in enumerate(machines_cache):
if i > 0:
logger.info("Retrying on %s", base_uri)
try:
response = request_executor(method, base_uri + path, **kwargs)
response = request_executor(method, base_uri + path, fields=fields, **kwargs)
response.data.decode('utf-8')
self._check_cluster_id(response)
if some_request_failed:
self.set_base_uri(base_uri)
self._refresh_machines_cache()
@@ -231,12 +218,20 @@ class AbstractEtcdClientWithFailover(etcd.Client):
raise etcd.EtcdConnectionFailed('No more machines in the cluster')
@abc.abstractmethod
def _prepare_request(self, kwargs, params=None, method=None):
"""returns: request_executor"""
def api_execute(self, path, method, params=None, timeout=None):
if not path.startswith('/'):
raise ValueError('Path does not start with /')
retry = params.pop('retry', None) if isinstance(params, dict) else None
kwargs = {'fields': params, 'preload_content': False}
if method in [self._MGET, self._MDELETE]:
request_executor = self.http.request
elif method in [self._MPUT, self._MPOST]:
request_executor = self.http.request_encode_body
kwargs['encode_multipart'] = False
else:
raise etcd.EtcdException('HTTP method {0} not supported'.format(method))
# Update machines_cache if previous attempt of update has failed
if self._update_machines_cache:
@@ -246,9 +241,7 @@ class AbstractEtcdClientWithFailover(etcd.Client):
machines_cache = self.machines_cache
etcd_nodes = len(machines_cache)
kwargs = self._prepare_common_parameters(etcd_nodes, timeout)
request_executor = self._prepare_request(kwargs, params, method)
kwargs.update(self._build_request_parameters(etcd_nodes, timeout))
while True:
try:
@@ -396,47 +389,17 @@ class AbstractEtcdClientWithFailover(etcd.Client):
self._base_uri = value
class EtcdClient(AbstractEtcdClientWithFailover):
class Etcd(AbstractDCS):
ERROR_CLS = EtcdError
def __del__(self):
if self.http is not None:
try:
self.http.clear()
except (ReferenceError, TypeError):
pass
def _prepare_get_members(self, etcd_nodes):
return self._prepare_common_parameters(etcd_nodes)
def _get_members(self, base_uri, **kwargs):
response = self.http.request(self._MGET, base_uri + self.version_prefix + '/machines', **kwargs)
data = self._handle_server_response(response).data.decode('utf-8')
return [m.strip() for m in data.split(',') if m.strip()]
def _prepare_request(self, kwargs, params=None, method=None):
kwargs['fields'] = params
if method in (self._MPOST, self._MPUT):
kwargs['encode_multipart'] = False
return self.http.request
class AbstractEtcd(AbstractDCS):
def __init__(self, config, client_cls, retry_errors_cls):
super(AbstractEtcd, self).__init__(config)
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=retry_errors_cls)
def __init__(self, config):
super(Etcd, self).__init__(config)
self._ttl = int(config.get('ttl') or 30)
self._client = self.get_etcd_client(config, client_cls)
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=(etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
self._client = self.get_etcd_client(config)
self.__do_not_watch = False
self._has_failed = False
def reload_config(self, config):
super(AbstractEtcd, self).reload_config(config)
self._client.reload_config(config.get(self.__class__.__name__.lower(), {}))
def retry(self, *args, **kwargs):
retry = self._retry.copy()
kwargs['retry'] = retry
@@ -453,13 +416,22 @@ class AbstractEtcd(AbstractDCS):
if isinstance(raise_ex, Exception):
raise raise_ex
@staticmethod
def set_socket_options(sock, socket_options):
if socket_options:
for opt in socket_options:
sock.setsockopt(*opt)
def catch_etcd_errors(func):
def wrapper(self, *args, **kwargs):
try:
retval = func(self, *args, **kwargs) is not None
self._has_failed = False
return retval
except (RetryFailedError, etcd.EtcdException) as e:
self._handle_exception(e)
return False
except Exception as e:
self._handle_exception(e, raise_ex=EtcdError('unexpected error'))
def get_etcd_client(self, config, client_cls):
return wrapper
@staticmethod
def get_etcd_client(config):
if 'proxy' in config:
config['use_proxies'] = True
config['url'] = config['proxy']
@@ -508,7 +480,9 @@ class AbstractEtcd(AbstractDCS):
sock = None
try:
sock = socket.socket(af, socktype, proto)
self.set_socket_options(sock, socket_options)
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:
@@ -532,7 +506,7 @@ class AbstractEtcd(AbstractDCS):
client = None
while not client:
try:
client = client_cls(config, dns_resolver)
client = Client(config, dns_resolver)
if 'use_proxies' in config and not client.machines:
raise etcd.EtcdException
except etcd.EtcdException:
@@ -542,10 +516,9 @@ class AbstractEtcd(AbstractDCS):
def set_ttl(self, ttl):
ttl = int(ttl)
ret = self._ttl != ttl
self.__do_not_watch = self._ttl != ttl
self._ttl = ttl
self._client.set_machines_cache_ttl(ttl*10)
return ret
@property
def ttl(self):
@@ -555,31 +528,6 @@ class AbstractEtcd(AbstractDCS):
self._retry.deadline = retry_timeout
self._client.set_read_timeout(retry_timeout)
def catch_etcd_errors(func):
def wrapper(self, *args, **kwargs):
try:
retval = func(self, *args, **kwargs) is not None
self._has_failed = False
return retval
except (RetryFailedError, etcd.EtcdException) as e:
self._handle_exception(e)
return False
except Exception as e:
self._handle_exception(e, raise_ex=self._client.ERROR_CLS('unexpected error'))
return wrapper
class Etcd(AbstractEtcd):
def __init__(self, config):
super(Etcd, self).__init__(config, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
self.__do_not_watch = False
def set_ttl(self, ttl):
self.__do_not_watch = super(Etcd, self).set_ttl(ttl)
@staticmethod
def member(node):
return Member.from_node(node.modifiedIndex, os.path.basename(node.key), node.ttl, node.value)
@@ -710,10 +658,8 @@ class Etcd(AbstractEtcd):
while timeout >= 1: # when timeout is too small urllib3 doesn't have enough time to connect
try:
result = self._client.watch(self.leader_path, index=leader_index, timeout=timeout + 0.5)
self._client.watch(self.leader_path, index=leader_index, timeout=timeout + 0.5)
self._has_failed = False
if result.action == 'compareAndSwap':
time.sleep(0.01)
# Synchronous work of all cluster members with etcd is less expensive
# than reestablishing http connection every time from every replica.
return True
-795
View File
@@ -1,795 +0,0 @@
from __future__ import absolute_import
import base64
import etcd
import json
import logging
import os
import six
import socket
import sys
import time
import urllib3
from threading import Condition, Lock, Thread
from . import ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors
from ..exceptions import DCSError, PatroniException
from ..utils import deep_compare, enable_keepalive, iter_response_objects, RetryFailedError, USER_AGENT
logger = logging.getLogger(__name__)
class Etcd3Error(DCSError):
pass
class UnsupportedEtcdVersion(PatroniException):
pass
# google.golang.org/grpc/codes
GRPCCode = type('Enum', (), {'OK': 0, 'Canceled': 1, 'Unknown': 2, 'InvalidArgument': 3, 'DeadlineExceeded': 4,
'NotFound': 5, 'AlreadyExists': 6, 'PermissionDenied': 7, 'ResourceExhausted': 8,
'FailedPrecondition': 9, 'Aborted': 10, 'OutOfRange': 11, 'Unimplemented': 12,
'Internal': 13, 'Unavailable': 14, 'DataLoss': 15, 'Unauthenticated': 16})
GRPCcodeToText = {v: k for k, v in GRPCCode.__dict__.items() if not k.startswith('__') and isinstance(v, int)}
class Etcd3Exception(etcd.EtcdException):
pass
class Etcd3ClientError(Etcd3Exception):
def __init__(self, code=None, error=None, status=None):
if not hasattr(self, 'error'):
self.error = error and error.strip()
self.codeText = GRPCcodeToText.get(code)
self.status = status
def __repr__(self):
return "<{0} error: '{1}', code: {2}>".format(self.__class__.__name__, self.error, self.code)
__str__ = __repr__
def as_dict(self):
return {'error': self.error, 'code': self.code, 'codeText': self.codeText, 'status': self.status}
@classmethod
def get_subclasses(cls):
for subclass in cls.__subclasses__():
for subsubclass in subclass.get_subclasses():
yield subsubclass
yield subclass
class Unknown(Etcd3ClientError):
code = GRPCCode.Unknown
class InvalidArgument(Etcd3ClientError):
code = GRPCCode.InvalidArgument
class DeadlineExceeded(Etcd3ClientError):
code = GRPCCode.DeadlineExceeded
error = "context deadline exceeded"
class NotFound(Etcd3ClientError):
code = GRPCCode.NotFound
class FailedPrecondition(Etcd3ClientError):
code = GRPCCode.FailedPrecondition
class Unavailable(Etcd3ClientError):
code = GRPCCode.Unavailable
# https://github.com/etcd-io/etcd/blob/master/etcdserver/api/v3rpc/rpctypes/error.go
class LeaseNotFound(NotFound):
error = "etcdserver: requested lease not found"
class UserEmpty(InvalidArgument):
error = "etcdserver: user name is empty"
class PermissionDenied(Etcd3ClientError):
code = GRPCCode.PermissionDenied
error = "etcdserver: permission denied"
class AuthNotEnabled(FailedPrecondition):
error = "etcdserver: authentication is not enabled"
class InvalidAuthToken(Etcd3ClientError):
code = GRPCCode.Unauthenticated
error = "etcdserver: invalid auth token"
errStringToClientError = {s.error: s for s in Etcd3ClientError.get_subclasses() if hasattr(s, 'error')}
errCodeToClientError = {s.code: s for s in Etcd3ClientError.__subclasses__()}
def _raise_for_data(data, status_code=None):
try:
error = data.get('error') or data.get('Error')
if isinstance(error, dict): # streaming response
status_code = error.get('http_code')
code = error['grpc_code']
error = error['message']
else:
code = data.get('code') or data.get('Code')
except Exception:
error = str(data)
code = GRPCCode.Unknown
err = errStringToClientError.get(error) or errCodeToClientError.get(code) or Unknown
raise err(code, error, status_code)
def to_bytes(v):
return v if isinstance(v, bytes) else v.encode('utf-8')
def prefix_range_end(v):
v = bytearray(to_bytes(v))
for i in range(len(v) - 1, -1, -1):
if v[i] < 0xff:
v[i] += 1
break
return bytes(v)
def base64_encode(v):
return base64.b64encode(to_bytes(v)).decode('utf-8')
def base64_decode(v):
return base64.b64decode(v).decode('utf-8')
def build_range_request(key, range_end=None):
fields = {'key': base64_encode(key)}
if range_end:
fields['range_end'] = base64_encode(range_end)
return fields
class Etcd3Client(AbstractEtcdClientWithFailover):
ERROR_CLS = Etcd3Error
def __init__(self, config, dns_resolver, cache_ttl=300):
self._token = None
self._cluster_version = None
self.version_prefix = '/v3beta'
super(Etcd3Client, self).__init__(config, dns_resolver, cache_ttl)
if six.PY2: # pragma: no cover
# Old grpc-gateway sometimes sends double 'transfer-encoding: chunked' headers,
# what breaks the old (python2.7) httplib.HTTPConnection (it closes the socket).
def dedup_addheader(httpm, key, value):
prev = httpm.dict.get(key)
if prev is None:
httpm.dict[key] = value
elif key != 'transfer-encoding' or prev != value:
combined = ", ".join((prev, value))
httpm.dict[key] = combined
import httplib
httplib.HTTPMessage.addheader = dedup_addheader
try:
self.authenticate()
except Exception as e:
logger.fatal('Etcd3 authentication failed: %r', e)
sys.exit(1)
def _get_headers(self):
headers = urllib3.make_headers(user_agent=USER_AGENT)
if self._token and self._cluster_version >= (3, 3, 0):
headers['authorization'] = self._token
return headers
def _prepare_request(self, kwargs, params=None, method=None):
if params is not None:
kwargs['body'] = json.dumps(params)
kwargs['headers']['Content-Type'] = 'application/json'
return self.http.urlopen
@staticmethod
def _handle_server_response(response):
data = response.data
try:
data = data.decode('utf-8')
data = json.loads(data)
except (TypeError, ValueError, UnicodeError) as e:
if response.status < 400:
raise etcd.EtcdException('Server response was not valid JSON: %r' % e)
if response.status < 400:
return data
_raise_for_data(data, response.status)
def _ensure_version_prefix(self, base_uri, **kwargs):
if self.version_prefix != '/v3':
response = self.http.urlopen(self._MGET, base_uri + '/version', **kwargs)
response = self._handle_server_response(response)
server_version_str = response['etcdserver']
server_version = tuple(int(x) for x in server_version_str.split('.'))
cluster_version_str = response['etcdcluster']
self._cluster_version = tuple(int(x) for x in cluster_version_str.split('.'))
if self._cluster_version < (3, 0) or server_version < (3, 0, 4):
raise UnsupportedEtcdVersion('Detected Etcd version {0} is lower than 3.0.4'.format(server_version_str))
if self._cluster_version < (3, 3):
if self.version_prefix != '/v3alpha':
if self._cluster_version < (3, 1):
logger.warning('Detected Etcd version %s is lower than 3.1.0, watches are not supported',
cluster_version_str)
if self.username and self.password:
logger.warning('Detected Etcd version %s is lower than 3.3.0, authentication is not supported',
cluster_version_str)
self.version_prefix = '/v3alpha'
elif self._cluster_version < (3, 4):
self.version_prefix = '/v3beta'
else:
self.version_prefix = '/v3'
def _prepare_get_members(self, etcd_nodes):
kwargs = self._prepare_common_parameters(etcd_nodes)
self._prepare_request(kwargs, {})
return kwargs
def _get_members(self, base_uri, **kwargs):
self._ensure_version_prefix(base_uri, **kwargs)
resp = self.http.urlopen(self._MPOST, base_uri + self.version_prefix + '/cluster/member/list', **kwargs)
members = self._handle_server_response(resp)['members']
return set(url for member in members for url in member.get('clientURLs', []))
def call_rpc(self, method, fields, retry=None):
fields['retry'] = retry
return self.api_execute(self.version_prefix + method, self._MPOST, fields)
def authenticate(self):
if self._cluster_version >= (3, 3) and self.username and self.password:
logger.info('Trying to authenticate on Etcd...')
old_token, self._token = self._token, None
try:
response = self.call_rpc('/auth/authenticate', {'name': self.username, 'password': self.password})
except AuthNotEnabled:
logger.info('Etcd authentication is not enabled')
self._token = None
except Exception:
self._token = old_token
raise
else:
self._token = response.get('token')
return old_token != self._token
def _handle_auth_errors(func):
def wrapper(self, *args, **kwargs):
def retry(ex):
if self.username and self.password:
self.authenticate()
return func(self, *args, **kwargs)
else:
logger.fatal('Username or password not set, authentication is not possible')
raise ex
try:
return func(self, *args, **kwargs)
except (UserEmpty, PermissionDenied) as e: # no token provided
# PermissionDenied is raised on 3.0 and 3.1
if self._cluster_version < (3, 3) and (not isinstance(e, PermissionDenied)
or self._cluster_version < (3, 2)):
raise UnsupportedEtcdVersion('Authentication is required by Etcd cluster but not '
'supported on version lower than 3.3.0. Cluster version: '
'{0}'.format('.'.join(map(str, self._cluster_version))))
return retry(e)
except InvalidAuthToken as e:
logger.error('Invalid auth token: %s', self._token)
return retry(e)
return wrapper
@_handle_auth_errors
def range(self, key, range_end=None, retry=None):
params = build_range_request(key, range_end)
params['serializable'] = True # For better performance. We can tolerate stale reads.
return self.call_rpc('/kv/range', params, retry)
def prefix(self, key, retry=None):
return self.range(key, prefix_range_end(key), retry)
def lease_grant(self, ttl, retry=None):
return self.call_rpc('/lease/grant', {'TTL': ttl}, retry)['ID']
def lease_keepalive(self, ID, retry=None):
return self.call_rpc('/lease/keepalive', {'ID': ID}, retry).get('result', {}).get('TTL')
def txn(self, compare, success, retry=None):
return self.call_rpc('/kv/txn', {'compare': [compare], 'success': [success]}, retry).get('succeeded')
@_handle_auth_errors
def put(self, key, value, lease=None, create_revision=None, mod_revision=None, retry=None):
fields = {'key': base64_encode(key), 'value': base64_encode(value)}
if lease:
fields['lease'] = lease
if create_revision is not None:
compare = {'target': 'CREATE', 'create_revision': create_revision}
elif mod_revision is not None:
compare = {'target': 'MOD', 'mod_revision': mod_revision}
else:
return self.call_rpc('/kv/put', fields, retry)
compare['key'] = fields['key']
return self.txn(compare, {'request_put': fields}, retry)
@_handle_auth_errors
def deleterange(self, key, range_end=None, mod_revision=None, retry=None):
fields = build_range_request(key, range_end)
if mod_revision is None:
return self.call_rpc('/kv/deleterange', fields, retry)
compare = {'target': 'MOD', 'mod_revision': mod_revision, 'key': fields['key']}
return self.txn(compare, {'request_delete_range': fields}, retry)
def deleteprefix(self, key, retry=None):
return self.deleterange(key, prefix_range_end(key), retry=retry)
def watchrange(self, key, range_end=None, start_revision=None, filters=None):
"""returns: response object"""
params = build_range_request(key, range_end)
if start_revision is not None:
params['start_revision'] = start_revision
params['filters'] = filters or []
kwargs = self._prepare_common_parameters(1, self.read_timeout)
request_executor = self._prepare_request(kwargs, {'create_request': params})
kwargs.update(timeout=urllib3.Timeout(connect=kwargs['timeout']), retries=0)
return request_executor(self._MPOST, self._base_uri + self.version_prefix + '/watch', **kwargs)
def watchprefix(self, key, start_revision=None, filters=None):
return self.watchrange(key, prefix_range_end(key), start_revision, filters)
class KVCache(Thread):
def __init__(self, dcs, client):
Thread.__init__(self)
self.daemon = True
self._dcs = dcs
self._client = client
self.condition = Condition()
self._config_key = base64_encode(dcs.config_path)
self._leader_key = base64_encode(dcs.leader_path)
self._optime_key = base64_encode(dcs.leader_optime_path)
self._name = base64_encode(dcs._name)
self._is_ready = False
self._response = None
self._response_lock = Lock()
self._object_cache = {}
self._object_cache_lock = Lock()
self.start()
def set(self, value, overwrite=False):
with self._object_cache_lock:
name = value['key']
old_value = self._object_cache.get(name)
ret = not old_value or int(old_value['mod_revision']) < int(value['mod_revision'])
if ret or overwrite and old_value['mod_revision'] == value['mod_revision']:
self._object_cache[name] = value
return ret, old_value
def delete(self, name, mod_revision):
with self._object_cache_lock:
old_value = self._object_cache.get(name)
ret = old_value and int(old_value['mod_revision']) < int(mod_revision)
if ret:
del self._object_cache[name]
return not old_value or ret, old_value
def copy(self):
with self._object_cache_lock:
return [v.copy() for v in self._object_cache.values()]
def get(self, name):
with self._object_cache_lock:
return self._object_cache.get(name)
def _process_event(self, event):
kv = event['kv']
key = kv['key']
if event.get('type') == 'DELETE':
success, old_value = self.delete(key, kv['mod_revision'])
else:
success, old_value = self.set(kv, True)
if success:
old_value = old_value and old_value.get('value')
new_value = kv.get('value')
value_changed = old_value != new_value and \
(key == self._leader_key or key == self._optime_key and new_value is not None or
key == self._config_key and old_value is not None and new_value is not None)
if value_changed:
logger.debug('%s changed from %s to %s', key, old_value, new_value)
# We also want to wake up HA loop on replicas if leader optime was updated
if value_changed and (key != self._optime_key or self.get(self._leader_key) != self._name):
self._dcs.event.set()
def _process_message(self, message):
logger.debug('Received message: %s', message)
if 'error' in message:
_raise_for_data(message)
for event in message.get('result', {}).get('events', []):
self._process_event(event)
@staticmethod
def _finish_response(response):
try:
response.close()
finally:
response.release_conn()
def _do_watch(self, revision):
with self._response_lock:
self._response = None
response = self._client.watchprefix(self._dcs.cluster_prefix, revision)
with self._response_lock:
if self._response is None:
self._response = response
if not self._response:
return self._finish_response(response)
for message in iter_response_objects(response):
self._process_message(message)
def _build_cache(self):
result = self._dcs.retry(self._client.prefix, self._dcs.cluster_prefix)
with self._object_cache_lock:
self._object_cache = {node['key']: node for node in result.get('kvs', [])}
with self.condition:
self._is_ready = True
self.condition.notify()
try:
self._do_watch(result['header']['revision'])
except Exception as e:
logger.error('watchprefix failed: %r', e)
finally:
with self.condition:
self._is_ready = False
with self._response_lock:
response, self._response = self._response, None
if response:
self._finish_response(response)
def run(self):
while True:
try:
self._build_cache()
except Exception as e:
logger.error('KVCache.run %r', e)
time.sleep(1)
def kill_stream(self):
sock = None
with self._response_lock:
if self._response:
try:
sock = self._response.connection.sock
except Exception:
sock = None
else:
self._response = False
if sock:
try:
sock.shutdown(socket.SHUT_RDWR)
sock.close()
except Exception as e:
logger.debug('Error on socket.shutdown: %r', e)
def is_ready(self):
"""Must be called only when holding the lock on `condition`"""
return self._is_ready
class PatroniEtcd3Client(Etcd3Client):
def __init__(self, *args, **kwargs):
self._kv_cache = None
super(PatroniEtcd3Client, self).__init__(*args, **kwargs)
def configure(self, etcd3):
self._etcd3 = etcd3
def start_watcher(self):
if self._cluster_version >= (3, 1):
self._kv_cache = KVCache(self._etcd3, self)
def _restart_watcher(self):
if self._kv_cache:
self._kv_cache.kill_stream()
def set_base_uri(self, value):
super(PatroniEtcd3Client, self).set_base_uri(value)
self._restart_watcher()
def authenticate(self):
ret = super(PatroniEtcd3Client, self).authenticate()
if ret:
self._restart_watcher()
return ret
def _wait_cache(self, timeout):
stop_time = time.time() + timeout
while not self._kv_cache.is_ready():
timeout = stop_time - time.time()
if timeout <= 0:
raise RetryFailedError('Exceeded retry deadline')
self._kv_cache.condition.wait(timeout)
def get_cluster(self):
if self._kv_cache:
with self._kv_cache.condition:
self._wait_cache(self._etcd3._retry.deadline)
return self._kv_cache.copy()
else:
return self._etcd3.retry(self.prefix, self._etcd3.cluster_prefix).get('kvs', [])
def call_rpc(self, method, fields, retry=None):
ret = super(PatroniEtcd3Client, self).call_rpc(method, fields, retry)
if self._kv_cache:
value = delete = None
if method == '/kv/txn' and ret.get('succeeded'):
on_success = fields['success'][0]
value = on_success.get('request_put')
delete = on_success.get('request_delete_range')
elif method == '/kv/put' and ret:
value = fields
elif method == '/kv/deleterange' and ret:
delete = fields
if value:
value['mod_revision'] = ret['header']['revision']
self._kv_cache.set(value)
elif delete and 'range_end' not in delete:
self._kv_cache.delete(delete['key'], ret['header']['revision'])
return ret
class Etcd3(AbstractEtcd):
def __init__(self, config):
super(Etcd3, self).__init__(config, PatroniEtcd3Client, (DeadlineExceeded, Unavailable, FailedPrecondition))
self.__do_not_watch = False
self._lease = None
self._last_lease_refresh = 0
self._client.configure(self)
if not self._ctl:
self._client.start_watcher()
self.create_lease()
def set_socket_options(self, sock, socket_options):
enable_keepalive(sock, self.ttl, int(self.loop_wait + self._retry.deadline))
def set_ttl(self, ttl):
self.__do_not_watch = super(Etcd3, self).set_ttl(ttl)
if self.__do_not_watch:
self._lease = None
def _do_refresh_lease(self, retry=None):
if self._lease and self._last_lease_refresh + self._loop_wait > time.time():
return False
if self._lease and not self._client.lease_keepalive(self._lease, retry):
self._lease = None
ret = not self._lease
if ret:
self._lease = self._client.lease_grant(self._ttl, retry)
self._last_lease_refresh = time.time()
return ret
def refresh_lease(self):
try:
return self.retry(self._do_refresh_lease)
except (Etcd3ClientError, RetryFailedError):
logger.exception('refresh_lease')
raise Etcd3Error('Failed ro keepalive/grant lease')
def create_lease(self):
while not self._lease:
try:
self.refresh_lease()
except Etcd3Error:
logger.info('waiting on etcd')
time.sleep(5)
@property
def cluster_prefix(self):
return self.client_path('')
@staticmethod
def member(node):
return Member.from_node(node['mod_revision'], os.path.basename(node['key']), node['lease'], node['value'])
def _load_cluster(self):
cluster = None
try:
path_len = len(self.cluster_prefix)
nodes = {}
for node in self._client.get_cluster():
node['key'] = base64_decode(node['key'])
node['value'] = base64_decode(node.get('value', ''))
node['lease'] = node.get('lease')
nodes[node['key'][path_len:].lstrip('/')] = node
# get initialize flag
initialize = nodes.get(self._INITIALIZE)
initialize = initialize and initialize['value']
# get global dynamic configuration
config = nodes.get(self._CONFIG)
config = config and ClusterConfig.from_node(config['mod_revision'], config['value'])
# get timeline history
history = nodes.get(self._HISTORY)
history = history and TimelineHistory.from_node(history['mod_revision'], history['value'])
# get last leader operation
last_leader_operation = nodes.get(self._LEADER_OPTIME)
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['value'])
# get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
# get leader
leader = nodes.get(self._LEADER)
if not self._ctl and leader and leader['value'] == self._name and self._lease != leader.get('lease'):
logger.warning('I am the leader but not owner of the lease')
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['mod_revision'], leader['lease'], member)
# failover key
failover = nodes.get(self._FAILOVER)
if failover:
failover = Failover.from_node(failover['mod_revision'], failover['value'])
# get synchronization state
sync = nodes.get(self._SYNC)
sync = SyncState.from_node(sync and sync['mod_revision'], sync and sync['value'])
cluster = Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
except UnsupportedEtcdVersion:
raise
except Exception as e:
self._handle_exception(e, 'get_cluster', raise_ex=Etcd3Error('Etcd is not responding properly'))
self._has_failed = False
return cluster
@catch_etcd_errors
def touch_member(self, data, permanent=False):
if not permanent:
try:
self.refresh_lease()
except Etcd3Error:
return False
cluster = self.cluster
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
if member and member.session == self._lease and deep_compare(data, member.data):
return True
data = json.dumps(data, separators=(',', ':'))
try:
return self._client.put(self.member_path, data, None if permanent else self._lease)
except LeaseNotFound:
self._lease = None
logger.error('Our lease disappeared from Etcd, can not "touch_member"')
@catch_etcd_errors
def take_leader(self):
return self.retry(self._client.put, self.leader_path, self._name, self._lease)
@catch_etcd_errors
def _do_attempt_to_acquire_leader(self, permanent):
try:
return self.retry(self._client.put, self.leader_path, self._name, None if permanent else self._lease, 0)
except LeaseNotFound:
self._lease = None
logger.error('Our lease disappeared from Etcd. Will try to get a new one and retry attempt')
self.refresh_lease()
return self.retry(self._client.put, self.leader_path, self._name, None if permanent else self._lease, 0)
def attempt_to_acquire_leader(self, permanent=False):
if not self._lease and not permanent:
self.refresh_lease()
ret = self._do_attempt_to_acquire_leader(permanent)
if not ret:
logger.info('Could not take out TTL lock')
return ret
@catch_etcd_errors
def set_failover_value(self, value, index=None):
return self._client.put(self.failover_path, value, mod_revision=index)
@catch_etcd_errors
def set_config_value(self, value, index=None):
return self._client.put(self.config_path, value, mod_revision=index)
@catch_etcd_errors
def _write_leader_optime(self, last_operation):
return self._client.put(self.leader_optime_path, last_operation)
@catch_etcd_errors
def _update_leader(self):
if not self._lease:
self.refresh_lease()
elif self.retry(self._client.lease_keepalive, self._lease):
self._last_lease_refresh = time.time()
if self._lease:
cluster = self.cluster
leader_lease = cluster and isinstance(cluster.leader, Leader) and cluster.leader.session
if leader_lease != self._lease:
self.take_leader()
return bool(self._lease)
@catch_etcd_errors
def initialize(self, create_new=True, sysid=""):
return self.retry(self._client.put, self.initialize_path, sysid, None, 0 if create_new else None)
@catch_etcd_errors
def _delete_leader(self):
cluster = self.cluster
if cluster and isinstance(cluster.leader, Leader) and cluster.leader.name == self._name:
return self._client.deleterange(self.leader_path, mod_revision=cluster.leader.index)
@catch_etcd_errors
def cancel_initialization(self):
return self.retry(self._client.deleterange, self.initialize_path)
@catch_etcd_errors
def delete_cluster(self):
return self.retry(self._client.deleteprefix, self.cluster_prefix)
@catch_etcd_errors
def set_history_value(self, value):
return self._client.put(self.history_path, value)
@catch_etcd_errors
def set_sync_state_value(self, value, index=None):
return self.retry(self._client.put, self.sync_path, value, mod_revision=index)
@catch_etcd_errors
def delete_sync_state(self, index=None):
return self.retry(self._client.deleterange, self.sync_path, mod_revision=index)
def watch(self, leader_index, timeout):
if self.__do_not_watch:
self.__do_not_watch = False
return True
try:
return super(Etcd3, self).watch(None, timeout)
finally:
self.event.clear()
+36 -426
View File
@@ -1,16 +1,13 @@
from __future__ import absolute_import
import datetime
import functools
import json
import logging
import os
import random
import socket
import six
import sys
import time
import urllib3
import yaml
from kubernetes import client as k8s_client, config as k8s_config, watch as k8s_watch
from urllib3 import Timeout
from urllib3.exceptions import HTTPError
from six.moves.http_client import HTTPException
@@ -18,410 +15,15 @@ from threading import Condition, Lock, Thread
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, TimelineHistory
from ..exceptions import DCSError
from ..utils import deep_compare, iter_response_objects, keepalive_socket_options,\
Retry, RetryFailedError, tzutc, uri, USER_AGENT
from ..utils import deep_compare, Retry, RetryFailedError, tzutc, USER_AGENT
logger = logging.getLogger(__name__)
KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', '~/.kube/config')
SERVICE_HOST_ENV_NAME = 'KUBERNETES_SERVICE_HOST'
SERVICE_PORT_ENV_NAME = 'KUBERNETES_SERVICE_PORT'
SERVICE_TOKEN_FILENAME = '/var/run/secrets/kubernetes.io/serviceaccount/token'
SERVICE_CERT_FILENAME = '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'
class KubernetesError(DCSError):
pass
# this function does the same mapping of snake_case => camelCase for > 97% of cases as autogenerated swagger code
def to_camel_case(value):
reserved = {'api', 'apiv3', 'cidr', 'cpu', 'csi', 'id', 'io', 'ip', 'ipc', 'pid', 'tls', 'uri', 'url', 'uuid'}
words = value.split('_')
return words[0] + ''.join(w.upper() if w in reserved else w.title() for w in words[1:])
class K8sConfig(object):
class ConfigException(Exception):
pass
def __init__(self):
self.pool_config = {'maxsize': 10, 'num_pools': 10} # configuration for urllib3.PoolManager
self._make_headers()
def _make_headers(self, token=None, **kwargs):
self._headers = urllib3.make_headers(user_agent=USER_AGENT, **kwargs)
if token:
self._headers['authorization'] = 'Bearer ' + token
def load_incluster_config(self):
if SERVICE_HOST_ENV_NAME not in os.environ or SERVICE_PORT_ENV_NAME not in os.environ:
raise self.ConfigException('Service host/port is not set.')
if not os.environ[SERVICE_HOST_ENV_NAME] or not os.environ[SERVICE_PORT_ENV_NAME]:
raise self.ConfigException('Service host/port is set but empty.')
if not os.path.isfile(SERVICE_CERT_FILENAME):
raise self.ConfigException('Service certificate file does not exists.')
with open(SERVICE_CERT_FILENAME) as f:
if not f.read():
raise self.ConfigException('Cert file exists but empty.')
if not os.path.isfile(SERVICE_TOKEN_FILENAME):
raise self.ConfigException('Service token file does not exists.')
with open(SERVICE_TOKEN_FILENAME) as f:
token = f.read()
if not token:
raise self.ConfigException('Token file exists but empty.')
self._make_headers(token=token)
self.pool_config['ca_certs'] = SERVICE_CERT_FILENAME
self._server = uri('https', (os.environ[SERVICE_HOST_ENV_NAME], os.environ[SERVICE_PORT_ENV_NAME]))
@staticmethod
def _get_by_name(config, section, name):
for c in config[section + 's']:
if c['name'] == name:
return c[section]
def load_kube_config(self, context=None):
with open(os.path.expanduser(KUBE_CONFIG_DEFAULT_LOCATION)) as f:
config = yaml.safe_load(f)
context = self._get_by_name(config, 'context', context or config['current-context'])
cluster = self._get_by_name(config, 'cluster', context['cluster'])
user = self._get_by_name(config, 'user', context['user'])
self._server = cluster['server'].rstrip('/')
if self._server.startswith('https'):
self.pool_config.update({v: user[k] for k, v in {'client-certificate': 'cert_file',
'client-key': 'key_file'}.items() if k in user})
if 'certificate-authority' in cluster:
self.pool_config['ca_certs'] = cluster['certificate-authority']
self.pool_config['cert_reqs'] = 'CERT_NONE' if cluster.get('insecure-skip-tls-verify') else 'CERT_REQUIRED'
if user.get('token'):
self._make_headers(token=user['token'])
elif 'username' in user and 'password' in user:
self._headers = self._make_headers(basic_auth=':'.join((user['username'], user['password'])))
@property
def server(self):
return self._server
@property
def headers(self):
return self._headers.copy()
class K8sObject(object):
def __init__(self, kwargs):
self._dict = {k: self._wrap(k, v) for k, v in kwargs.items()}
def get(self, name, default=None):
return self._dict.get(name, default)
def __getattr__(self, name):
return self.get(to_camel_case(name))
@classmethod
def _wrap(cls, parent, value):
if isinstance(value, dict):
# we know that `annotations` and `labels` are dicts and therefore don't want to convert them into K8sObject
return value if parent in {'annotations', 'labels'} and \
all(isinstance(v, six.string_types) for v in value.values()) else cls(value)
elif isinstance(value, list):
return [cls._wrap(None, v) for v in value]
else:
return value
def to_dict(self):
return self._dict
def __repr__(self):
return json.dumps(self, indent=4, default=lambda o: o.to_dict())
class K8sException(Exception):
pass
class K8sConnectionFailed(K8sException):
pass
class K8sClient(object):
class rest(object):
class ApiException(Exception):
def __init__(self, status=None, reason=None, http_resp=None):
self.status = http_resp.status if http_resp else status
self.reason = http_resp.reason if http_resp else reason
self.body = http_resp.data if http_resp else None
self.headers = http_resp.getheaders() if http_resp else None
def __str__(self):
error_message = "({0})\nReason: {1}\n".format(self.status, self.reason)
if self.headers:
error_message += "HTTP response headers: {0}\n".format(self.headers)
if self.body:
error_message += "HTTP response body: {0}\n".format(self.body)
return error_message
class ApiClient(object):
_API_URL_PREFIX = '/api/v1/namespaces/'
def __init__(self, bypass_api_service=False):
self._bypass_api_service = bypass_api_service
self.pool_manager = urllib3.PoolManager(**k8s_config.pool_config)
self._base_uri = k8s_config.server
self._api_servers_cache = [k8s_config.server]
self._api_servers_cache_updated = 0
self.set_api_servers_cache_ttl(10)
self.set_read_timeout(10)
try:
self._load_api_servers_cache()
except K8sException:
pass
def set_read_timeout(self, timeout):
self._read_timeout = timeout
def set_api_servers_cache_ttl(self, ttl):
self._api_servers_cache_ttl = ttl - 0.5
def set_base_uri(self, value):
logger.info('Selected new K8s API server endpoint %s', value)
# We will connect by IP of the master node which is not listed as alternative name
self.pool_manager.connection_pool_kw['assert_hostname'] = False
self._base_uri = value
@staticmethod
def _handle_server_response(response, _preload_content):
if response.status not in range(200, 206):
raise k8s_client.rest.ApiException(http_resp=response)
return K8sObject(json.loads(response.data.decode('utf-8'))) if _preload_content else response
@staticmethod
def _make_headers(headers):
ret = k8s_config.headers
ret.update(headers or {})
return ret
@property
def api_servers_cache(self):
base_uri, cache = self._base_uri, self._api_servers_cache
return ([base_uri] if base_uri in cache else []) + [machine for machine in cache if machine != base_uri]
def _get_api_servers(self, api_servers_cache):
_, per_node_timeout, per_node_retries = self._calculate_timeouts(len(api_servers_cache))
kwargs = {'headers': self._make_headers({}), 'preload_content': True, 'retries': per_node_retries,
'timeout': urllib3.Timeout(connect=max(1, per_node_timeout/2.0), total=per_node_timeout)}
path = self._API_URL_PREFIX + 'default/endpoints/kubernetes'
for base_uri in api_servers_cache:
try:
response = self.pool_manager.request('GET', base_uri + path, **kwargs)
endpoint = self._handle_server_response(response, True)
for subset in endpoint.subsets:
for port in subset.ports:
if port.name == 'https' and port.protocol == 'TCP':
addresses = [uri('https', (a.ip, port.port)) for a in subset.addresses]
if addresses:
random.shuffle(addresses)
return addresses
except Exception as e:
if isinstance(e, k8s_client.rest.ApiException) and e.status == 403:
raise
self.pool_manager.clear()
logger.error('Failed to get "kubernetes" endpoint from %s: %r', base_uri, e)
raise K8sConnectionFailed('No more K8s API server nodes in the cluster')
def _refresh_api_servers_cache(self, updating_cache=False):
if self._bypass_api_service:
try:
api_servers_cache = [k8s_config.server] if updating_cache else self.api_servers_cache
self._api_servers_cache = self._get_api_servers(api_servers_cache)
if updating_cache:
self.pool_manager.clear()
except k8s_client.rest.ApiException: # 403 Permission denied
logger.warning("Kubernetes RBAC doesn't allow GET access to the 'kubernetes' "
"endpoint in the 'default' namespace. Disabling 'bypass_api_service'.")
self._bypass_api_service = False
self._api_servers_cache = [k8s_config.server]
if not updating_cache:
self.pool_manager.clear()
except K8sConnectionFailed:
if updating_cache:
raise K8sException("Could not get the list of K8s API server nodes")
return
else:
self._api_servers_cache = [k8s_config.server]
if self._base_uri not in self._api_servers_cache:
self.set_base_uri(self._api_servers_cache[0])
self._api_servers_cache_updated = time.time()
def refresh_api_servers_cache(self):
if self._bypass_api_service and time.time() - self._api_servers_cache_updated > self._api_servers_cache_ttl:
self._refresh_api_servers_cache()
def _load_api_servers_cache(self):
self._update_api_servers_cache = True
self._refresh_api_servers_cache(True)
self._update_api_servers_cache = False
def _calculate_timeouts(self, api_servers, timeout=None):
"""Calculate a request timeout and number of retries per single K8s API server node.
In case if the timeout per node is too small (less than one second) we will reduce the number of nodes.
For the cluster with only one API server node we will try to do 1 retry.
No retries for clusters with 2 or more API server nodes. We better rely on switching to a different node."""
per_node_timeout = timeout = float(timeout or self._read_timeout)
max_retries = 3 - min(api_servers, 2)
per_node_retries = 1
min_timeout = 1.0
while api_servers > 0:
per_node_timeout = float(timeout) / api_servers
if per_node_timeout >= min_timeout:
# for small clusters we will try to do more than one try on every node
while per_node_retries < max_retries and per_node_timeout / (per_node_retries + 1) >= min_timeout:
per_node_retries += 1
per_node_timeout /= per_node_retries
break
# if the timeout per one node is to small try to reduce number of nodes
api_servers -= 1
max_retries = 1
return api_servers, per_node_timeout, per_node_retries - 1
def _do_http_request(self, retry, api_servers_cache, method, path, **kwargs):
some_request_failed = False
for i, base_uri in enumerate(api_servers_cache):
if i > 0:
logger.info('Retrying on %s', base_uri)
try:
response = self.pool_manager.request(method, base_uri + path, **kwargs)
if some_request_failed:
self.set_base_uri(base_uri)
self._refresh_api_servers_cache()
return response
except (HTTPError, HTTPException, socket.error, socket.timeout) as e:
self.pool_manager.clear()
if not retry:
# switch to the next node if request failed and retry is not allowed
if i + 1 < len(api_servers_cache):
self.set_base_uri(api_servers_cache[i + 1])
raise K8sException('{0} {1} request failed'.format(method, path))
logger.error('Request to server %s failed: %r', base_uri, e)
some_request_failed = True
raise K8sConnectionFailed('No more API server nodes in the cluster')
def request(self, retry, method, path, timeout=None, **kwargs):
if self._update_api_servers_cache:
self._load_api_servers_cache()
api_servers_cache = self.api_servers_cache
api_servers = len(api_servers_cache)
if timeout:
if isinstance(timeout, six.integer_types + (float,)):
timeout = urllib3.Timeout(total=timeout)
elif isinstance(timeout, tuple) and len(timeout) == 2:
timeout = urllib3.Timeout(connect=timeout[0], read=timeout[1])
retries = 0
else:
_, timeout, retries = self._calculate_timeouts(api_servers)
timeout = urllib3.Timeout(connect=max(1, timeout/2.0), total=timeout)
kwargs.update(retries=retries, timeout=timeout)
while True:
try:
return self._do_http_request(retry, api_servers_cache, method, path, **kwargs)
except K8sConnectionFailed as ex:
try:
self._load_api_servers_cache()
api_servers_cache = self.api_servers_cache
api_servers = len(api_servers)
except Exception as e:
logger.debug('Failed to update list of K8s master nodes: %r', e)
sleeptime = retry.sleeptime
remaining_time = retry.stoptime - sleeptime - time.time()
nodes, timeout, retries = self._calculate_timeouts(api_servers, remaining_time)
if nodes == 0:
self._update_api_servers_cache = True
raise ex
retry.sleep_func(sleeptime)
retry.update_delay()
# We still have some time left. Partially reduce `api_servers_cache` and retry request
kwargs.update(timeout=urllib3.Timeout(connect=max(1, timeout/2.0), total=timeout), retries=retries)
api_servers_cache = api_servers_cache[:nodes]
def call_api(self, method, path, headers=None, body=None, _retry=None,
_preload_content=True, _request_timeout=None, **kwargs):
headers = self._make_headers(headers)
fields = {to_camel_case(k): v for k, v in kwargs.items()} # resource_version => resourceVersion
body = json.dumps(body, default=lambda o: o.to_dict()) if body is not None else None
response = self.request(_retry, method, self._API_URL_PREFIX + path, headers=headers, fields=fields,
body=body, preload_content=_preload_content, timeout=_request_timeout)
return self._handle_server_response(response, _preload_content)
class CoreV1Api(object):
def __init__(self, api_client=None):
self._api_client = api_client or k8s_client.ApiClient()
def __getattr__(self, func): # `func` name pattern: (action)_namespaced_(kind)
action, kind = func.split('_namespaced_') # (read|list|create|patch|replace|delete|delete_collection)
kind = kind.replace('_', '') + ('s' * int(kind[-1] != 's')) # plural, single word
def wrapper(*args, **kwargs):
method = {'read': 'GET', 'list': 'GET', 'create': 'POST',
'replace': 'PUT'}.get(action, action.split('_')[0]).upper()
if action == 'create' or len(args) == 1: # namespace is a first argument and name in not in arguments
path = '/'.join([args[0], kind])
else: # name, namespace followed by optional body
path = '/'.join([args[1], kind, args[0]])
headers = {'Content-Type': 'application/strategic-merge-patch+json'} if action == 'patch' else {}
if len(args) == 3: # name, namespace, body
body = args[2]
elif action == 'create': # namespace, body
body = args[1]
elif action == 'delete': # name, namespace
body = kwargs.pop('body', None)
else:
body = None
return self._api_client.call_api(method, path, headers, body, **kwargs)
return wrapper
class _K8sObjectTemplate(K8sObject):
"""The template for objects which we create locally, e.g. k8s_client.V1ObjectMeta & co"""
def __init__(self, **kwargs):
self._dict = {to_camel_case(k): v for k, v in kwargs.items()}
def __init__(self):
self.__cls_cache = {}
self.__cls_lock = Lock()
def __getattr__(self, name):
with self.__cls_lock:
if name not in self.__cls_cache:
self.__cls_cache[name] = type(name, (self._K8sObjectTemplate,), {})
return self.__cls_cache[name]
k8s_client = K8sClient()
k8s_config = K8sConfig()
class KubernetesRetriableException(k8s_client.rest.ApiException):
def __init__(self, orig):
@@ -439,9 +41,11 @@ class KubernetesRetriableException(k8s_client.rest.ApiException):
class CoreV1ApiProxy(object):
def __init__(self, use_endpoints=False, bypass_api_service=False):
self._api_client = k8s_client.ApiClient(bypass_api_service)
self._core_v1_api = k8s_client.CoreV1Api(self._api_client)
def __init__(self, use_endpoints=False):
self._api = k8s_client.CoreV1Api()
self._api.api_client.user_agent = USER_AGENT
self._api.api_client.rest_client.pool_manager.connection_pool_kw['maxsize'] = 10
self._request_timeout = None
self._use_endpoints = bool(use_endpoints)
def configure_timeouts(self, loop_wait, retry_timeout, ttl):
@@ -449,21 +53,27 @@ class CoreV1ApiProxy(object):
# If we didn't received anything after the loop_wait + retry_timeout it is a time
# to start worrying (send keepalive messages). Finally, the connection should be
# considered as dead if we received nothing from the socket after the ttl seconds.
self._api_client.pool_manager.connection_pool_kw['socket_options'] = \
list(keepalive_socket_options(ttl, int(loop_wait + retry_timeout)))
self._api_client.set_read_timeout(retry_timeout)
self._api_client.set_api_servers_cache_ttl(loop_wait)
def refresh_api_servers_cache(self):
self._api_client.refresh_api_servers_cache()
cnt = 3
idle = int(loop_wait + retry_timeout)
intvl = max(1, int(float(ttl - idle) / cnt))
self._api.api_client.rest_client.pool_manager.connection_pool_kw['socket_options'] = [
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, idle),
(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, intvl),
(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, cnt),
(socket.IPPROTO_TCP, 18, int(ttl * 1000)) # TCP_USER_TIMEOUT
]
self._request_timeout = (1, retry_timeout / 3.0)
def __getattr__(self, func):
if func.endswith('_kind'):
func = func[:-4] + ('endpoints' if self._use_endpoints else 'config_map')
def wrapper(*args, **kwargs):
if '_request_timeout' not in kwargs:
kwargs['_request_timeout'] = self._request_timeout
try:
return getattr(self._core_v1_api, func)(*args, **kwargs)
return getattr(self._api, func)(*args, **kwargs)
except k8s_client.rest.ApiException as e:
if e.status in (500, 503, 504) or e.headers and 'retry-after' in e.headers: # XXX
raise KubernetesRetriableException(e)
@@ -485,7 +95,7 @@ def catch_kubernetes_errors(func):
elif e.status != 409: # Object exists or conflict in resource_version
logger.exception('Unexpected error from Kubernetes API')
return False
except (RetryFailedError, K8sException):
except (RetryFailedError, HTTPException, HTTPError, socket.error, socket.timeout):
return False
return wrapper
@@ -495,6 +105,7 @@ class ObjectCache(Thread):
def __init__(self, dcs, func, retry, condition, name=None):
Thread.__init__(self)
self.daemon = True
self._api_client = k8s_client.ApiClient()
self._dcs = dcs
self._func = func
self._retry = retry
@@ -508,7 +119,7 @@ class ObjectCache(Thread):
def _list(self):
try:
return self._func(_retry=self._retry.copy())
return self._func(_request_timeout=(self._retry.deadline, Timeout.DEFAULT_TIMEOUT))
except Exception:
time.sleep(1)
raise
@@ -552,7 +163,8 @@ class ObjectCache(Thread):
response = self._watch(objects.metadata.resource_version)
try:
for event in iter_response_objects(response):
for line in k8s_watch.watch.iter_resp_lines(response):
event = json.loads(line)
obj = event['object']
if obj.get('code') == 410:
break
@@ -561,7 +173,8 @@ class ObjectCache(Thread):
name = obj['metadata']['name']
if ev_type in ('ADDED', 'MODIFIED'):
obj = K8sObject(obj)
obj = k8s_watch.watch.SimpleNamespace(data=json.dumps(obj))
obj = self._api_client.deserialize(obj, return_type)
success, old_value = self.set(name, obj)
if success:
new_value = (obj.metadata.annotations or {}).get(self._annotations_map.get(name))
@@ -616,7 +229,8 @@ class Kubernetes(AbstractDCS):
config['namespace'] = ''
super(Kubernetes, self).__init__(config)
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=KubernetesRetriableException)
retry_exceptions=(KubernetesRetriableException, HTTPException,
HTTPError, socket.error, socket.timeout))
self._ttl = None
try:
k8s_config.load_incluster_config()
@@ -631,7 +245,7 @@ class Kubernetes(AbstractDCS):
port.update({n: p[n] for n in ('name', 'protocol') if p.get(n)})
self.__ports.append(k8s_client.V1EndpointPort(**port))
self._api = CoreV1ApiProxy(config.get('use_endpoints'), config.get('bypass_api_service'))
self._api = CoreV1ApiProxy(config.get('use_endpoints'))
self._should_create_config_service = self._api.use_endpoints
self.reload_config(config)
# leader_observed_record, leader_resource_version, and leader_observed_time are used only for leader race!
@@ -651,9 +265,7 @@ class Kubernetes(AbstractDCS):
self._kinds = ObjectCache(self, kinds_func, self._retry, self._condition, self._name)
def retry(self, *args, **kwargs):
retry = self._retry.copy()
kwargs['_retry'] = retry
return retry(*args, **kwargs)
return self._retry.copy()(*args, **kwargs)
def client_path(self, path):
return super(Kubernetes, self).client_path(path)[1:].replace('/', '-')
@@ -685,7 +297,8 @@ class Kubernetes(AbstractDCS):
member.data['pod_labels'] = pod.metadata.labels
return member
def _wait_caches(self, stop_time):
def _wait_caches(self):
stop_time = time.time() + self._retry.deadline
while not (self._pods.is_ready() and self._kinds.is_ready()):
timeout = stop_time - time.time()
if timeout <= 0:
@@ -693,11 +306,9 @@ class Kubernetes(AbstractDCS):
self._condition.wait(timeout)
def _load_cluster(self):
stop_time = time.time() + self._retry.deadline
self._api.refresh_api_servers_cache()
try:
with self._condition:
self._wait_caches(stop_time)
self._wait_caches()
members = [self.member(pod) for pod in self._pods.copy().values()]
nodes = self._kinds.copy()
@@ -890,7 +501,6 @@ class Kubernetes(AbstractDCS):
retry = self._retry.copy()
def _retry(*args, **kwargs):
kwargs['_retry'] = retry
return retry(*args, **kwargs)
try:
-409
View File
@@ -1,409 +0,0 @@
import json
import logging
import os
import threading
import time
from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
from pysyncobj import SyncObj, SyncObjConf, replicated, FAIL_REASON
from pysyncobj.transport import Node, TCPTransport, CONNECTION_STATE
logger = logging.getLogger(__name__)
class MessageNode(Node):
def __init__(self, address):
self.address = address
class UtilityTransport(TCPTransport):
def __init__(self, syncObj, selfNode, otherNodes):
super(UtilityTransport, self).__init__(syncObj, selfNode, otherNodes)
self._selfIsReadonlyNode = False
def _connectIfNecessarySingle(self, node):
pass
def connectionState(self, node):
return self._connections[node].state
def isDisconnected(self, node):
return self.connectionState(node) == CONNECTION_STATE.DISCONNECTED
def connectIfRequiredSingle(self, node):
if self.isDisconnected(node):
return self._connections[node].connect(node.ip, node.port)
def disconnectSingle(self, node):
self._connections[node].disconnect()
class SyncObjUtility(SyncObj):
def __init__(self, otherNodes, conf):
autoTick = conf.autoTick
conf.autoTick = False
super(SyncObjUtility, self).__init__(None, otherNodes, conf, transportClass=UtilityTransport)
conf.autoTick = autoTick
self._SyncObj__transport.setOnMessageReceivedCallback(self._onMessageReceived)
self.__result = None
def setPartnerNode(self, partner):
self.__node = partner
def sendMessage(self, message):
# Abuse the fact that node address is send as a first message
self._SyncObj__transport._selfNode = MessageNode(message)
self._SyncObj__transport.connectIfRequiredSingle(self.__node)
while not self._SyncObj__transport.isDisconnected(self.__node):
self._poller.poll(0.5)
return self.__result
def _onMessageReceived(self, _, message):
self.__result = message
self._SyncObj__transport.disconnectSingle(self.__node)
class MyTCPTransport(TCPTransport):
def _onIncomingMessageReceived(self, conn, message):
if self._syncObj.encryptor and not conn.sendRandKey:
conn.sendRandKey = message
conn.recvRandKey = os.urandom(32)
conn.send(conn.recvRandKey)
return
# Utility messages
if isinstance(message, list) and message[0] == 'members':
conn.send(self._syncObj._get_members())
return True
return super(MyTCPTransport, self)._onIncomingMessageReceived(conn, message)
class DynMemberSyncObj(SyncObj):
def __init__(self, selfAddress, partnerAddrs, conf):
add_self = False
if selfAddress:
utility = SyncObjUtility(partnerAddrs, conf)
for node in utility._SyncObj__otherNodes:
utility.setPartnerNode(node)
response = utility.sendMessage(['members'])
if response:
partnerAddrs = [member['addr'] for member in response if member['addr'] != selfAddress]
add_self = len(partnerAddrs) == len(response)
break
super(DynMemberSyncObj, self).__init__(selfAddress, partnerAddrs, conf, transportClass=MyTCPTransport)
if add_self:
threading.Thread(target=utility.sendMessage, args=(['add', selfAddress],)).start()
def _get_members(self):
ret = [{'addr': node.id, 'leader': node == self._getLeader(),
'status': CONNECTION_STATE.CONNECTED if node in self._SyncObj__connectedNodes
else CONNECTION_STATE.DISCONNECTED} for node in self._SyncObj__otherNodes]
ret.append({'addr': self._SyncObj__selfNode.id, 'leader': self._isLeader(),
'status': CONNECTION_STATE.CONNECTED})
return ret
def _SyncObj__doChangeCluster(self, request, reverse=False):
ret = False
if not self._SyncObj__selfNode or request[0] != 'add' or reverse or request[1] != self._SyncObj__selfNode.id:
ret = super(DynMemberSyncObj, self)._SyncObj__doChangeCluster(request, reverse)
if ret:
self.forceLogCompaction()
return ret
class KVStoreTTL(DynMemberSyncObj):
def __init__(self, selfAddress, partnerAddrs, conf, on_set=None, on_delete=None):
self.__on_set = on_set
self.__on_delete = on_delete
self.__limb = {}
self.__retry_timeout = None
self.__early_apply_local_log = selfAddress is not None
self.applied_local_log = False
super(KVStoreTTL, self).__init__(selfAddress, partnerAddrs, conf)
self.__data = {}
@staticmethod
def __check_requirements(old_value, **kwargs):
return ('prevExist' not in kwargs or bool(kwargs['prevExist']) == bool(old_value)) and \
('prevValue' not in kwargs or old_value and old_value['value'] == kwargs['prevValue']) and \
(not kwargs.get('prevIndex') or old_value and old_value['index'] == kwargs['prevIndex'])
def set_retry_timeout(self, retry_timeout):
self.__retry_timeout = retry_timeout
def retry(self, func, *args, **kwargs):
event = threading.Event()
ret = {'result': None, 'error': -1}
def callback(result, error):
ret.update(result=result, error=error)
event.set()
kwargs['callback'] = callback
timeout = kwargs.pop('timeout', None) or self.__retry_timeout
deadline = timeout and time.time() + timeout
while True:
event.clear()
func(*args, **kwargs)
event.wait(timeout)
if ret['error'] == FAIL_REASON.SUCCESS:
return ret['result']
elif ret['error'] == FAIL_REASON.REQUEST_DENIED:
break
elif deadline:
timeout = deadline - time.time()
if timeout <= 0:
break
time.sleep(1)
return False
@replicated
def _set(self, key, value, **kwargs):
old_value = self.__data.get(key, {})
if not self.__check_requirements(old_value, **kwargs):
return False
if old_value and old_value['created'] != value['created']:
value['created'] = value['updated']
value['index'] = self._SyncObj__raftLastApplied + 1
self.__data[key] = value
if self.__on_set:
self.__on_set(key, value)
return True
def set(self, key, value, ttl=None, **kwargs):
old_value = self.__data.get(key, {})
if not self.__check_requirements(old_value, **kwargs):
return False
value = {'value': value, 'updated': time.time()}
value['created'] = old_value.get('created', value['updated'])
if ttl:
value['expire'] = value['updated'] + ttl
return self.retry(self._set, key, value, **kwargs)
def __pop(self, key):
self.__data.pop(key)
if self.__on_delete:
self.__on_delete(key)
@replicated
def _delete(self, key, recursive=False, **kwargs):
if recursive:
for k in list(self.__data.keys()):
if k.startswith(key):
self.__pop(k)
elif not self.__check_requirements(self.__data.get(key, {}), **kwargs):
return False
else:
self.__pop(key)
return True
def delete(self, key, recursive=False, **kwargs):
if not recursive and not self.__check_requirements(self.__data.get(key, {}), **kwargs):
return False
return self.retry(self._delete, key, recursive=recursive, **kwargs)
@staticmethod
def __values_match(old, new):
return all(old.get(n) == new.get(n) for n in ('created', 'updated', 'expire', 'value'))
@replicated
def _expire(self, key, value, callback=None):
current = self.__data.get(key)
if current and self.__values_match(current, value):
self.__pop(key)
def __expire_keys(self):
for key, value in self.__data.items():
if value and 'expire' in value and value['expire'] <= time.time() and \
not (key in self.__limb and self.__values_match(self.__limb[key], value)):
self.__limb[key] = value
def callback(*args):
if key in self.__limb and self.__values_match(self.__limb[key], value):
self.__limb.pop(key)
self._expire(key, value, callback=callback)
def get(self, key, recursive=False):
if not recursive:
return self.__data.get(key)
return {k: v for k, v in self.__data.items() if k.startswith(key)}
def _onTick(self, timeToWait=0.0):
# The SyncObj starts applying the local log only when there is at least one node connected.
# We want to change this behavior and apply the local log even when there is nobody except us.
# It gives us at least some picture about the last known cluster state.
if self.__early_apply_local_log and not self.applied_local_log and self._SyncObj__needLoadDumpFile:
self._SyncObj__raftCommitIndex = self._SyncObj__getCurrentLogIndex()
self._SyncObj__raftCurrentTerm = self._SyncObj__getCurrentLogTerm()
super(KVStoreTTL, self)._onTick(timeToWait)
# The SyncObj calls onReady callback only when cluster got the leader and is ready for writes.
# In some cases for us it is safe to "signal" the Raft object when the local log is fully applied.
# We are using the `applied_local_log` property for that, but not calling the callback function.
if self.__early_apply_local_log and not self.applied_local_log and self._SyncObj__raftCommitIndex != 1 and \
self._SyncObj__raftLastApplied == self._SyncObj__raftCommitIndex:
self.applied_local_log = True
if self._isLeader():
self.__expire_keys()
else:
self.__limb.clear()
class Raft(AbstractDCS):
def __init__(self, config):
super(Raft, self).__init__(config)
self._ttl = int(config.get('ttl') or 30)
self_addr = None if self._ctl else config.get('self_addr')
template = os.path.join(config.get('data_dir', ''), self_addr or '')
files = {'journalFile': template + '.journal', 'fullDumpFile': template + '.dump'} if self_addr else {}
ready_event = threading.Event()
conf = SyncObjConf(commandsWaitLeader=False, appendEntriesUseBatch=False, onReady=ready_event.set,
dynamicMembershipChange=True, **files)
self._sync_obj = KVStoreTTL(self_addr, config.get('partner_addrs', []), conf, self._on_set, self._on_delete)
while True:
ready_event.wait(5)
if ready_event.isSet() or self._sync_obj.applied_local_log:
break
else:
logger.info('waiting on raft')
self._sync_obj.forceLogCompaction()
self.set_retry_timeout(int(config.get('retry_timeout') or 10))
def _on_set(self, key, value):
leader = (self._sync_obj.get(self.leader_path) or {}).get('value')
if key == value['created'] == value['updated'] and \
(key.startswith(self.members_path) or key == self.leader_path and leader != self._name) or \
key == self.leader_optime_path and leader != self._name or key in (self.config_path, self.sync_path):
self.event.set()
def _on_delete(self, key):
if key == self.leader_path:
self.event.set()
def set_ttl(self, ttl):
self._ttl = ttl
@property
def ttl(self):
return self._ttl
def set_retry_timeout(self, retry_timeout):
self._sync_obj.set_retry_timeout(retry_timeout)
@staticmethod
def member(key, value):
return Member.from_node(value['index'], os.path.basename(key), None, value['value'])
def _load_cluster(self):
prefix = self.client_path('')
response = self._sync_obj.get(prefix, recursive=True)
if not response:
return Cluster(None, None, None, None, [], None, None, None)
nodes = {os.path.relpath(key, prefix).replace('\\', '/'): value for key, value in response.items()}
# 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['index'], config['value'])
# get timeline history
history = nodes.get(self._HISTORY)
history = history and TimelineHistory.from_node(history['index'], history['value'])
# get last leader operation
last_leader_operation = nodes.get(self._LEADER_OPTIME)
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['value'])
# get list of members
members = [self.member(k, n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
# get leader
leader = nodes.get(self._LEADER)
if leader:
member = Member(-1, leader['value'], None, {})
member = ([m for m in members if m.name == leader['value']] or [member])[0]
leader = Leader(leader['index'], None, member)
# failover key
failover = nodes.get(self._FAILOVER)
if failover:
failover = Failover.from_node(failover['index'], failover['value'])
# get synchronization state
sync = nodes.get(self._SYNC)
sync = SyncState.from_node(sync and sync['index'], sync and sync['value'])
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history)
def _write_leader_optime(self, last_operation):
return self._sync_obj.set(self.leader_optime_path, last_operation, timeout=1)
def _update_leader(self):
ret = self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl, prevValue=self._name)
if not ret and self._sync_obj.get(self.leader_path) is None:
ret = self.attempt_to_acquire_leader()
return ret
def attempt_to_acquire_leader(self, permanent=False):
return self._sync_obj.set(self.leader_path, self._name, prevExist=False,
ttl=None if permanent else self._ttl)
def set_failover_value(self, value, index=None):
return self._sync_obj.set(self.failover_path, value, prevIndex=index)
def set_config_value(self, value, index=None):
return self._sync_obj.set(self.config_path, value, prevIndex=index)
def touch_member(self, data, permanent=False):
data = json.dumps(data, separators=(',', ':'))
return self._sync_obj.set(self.member_path, data, None if permanent else self._ttl, timeout=2)
def take_leader(self):
return self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl)
def initialize(self, create_new=True, sysid=''):
return self._sync_obj.set(self.initialize_path, sysid, prevExist=(not create_new))
def _delete_leader(self):
return self._sync_obj.delete(self.leader_path, prevValue=self._name, timeout=1)
def cancel_initialization(self):
return self._sync_obj.delete(self.initialize_path)
def delete_cluster(self):
return self._sync_obj.delete(self.client_path(''), recursive=True)
def set_history_value(self, value):
return self._sync_obj.set(self.history_path, value)
def set_sync_state_value(self, value, index=None):
return self._sync_obj.set(self.sync_path, value, prevIndex=index)
def delete_sync_state(self, index=None):
return self._sync_obj.delete(self.sync_path, prevIndex=index)
def watch(self, leader_index, timeout):
try:
return super(Raft, self).watch(leader_index, timeout)
finally:
self.event.clear()
+9 -32
View File
@@ -63,18 +63,13 @@ class ZooKeeper(AbstractDCS):
if isinstance(hosts, list):
hosts = ','.join(hosts)
mapping = {'use_ssl': 'use_ssl', 'verify': 'verify_certs', 'cacert': 'ca',
'cert': 'certfile', 'key': 'keyfile', 'key_password': 'keyfile_password'}
kwargs = {v: config[k] for k, v in mapping.items() if k in config}
self._client = KazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1,
sleep_func=time.sleep), command_retry=KazooRetry(deadline=config['retry_timeout'],
max_delay=1, max_tries=-1, sleep_func=time.sleep), **kwargs)
max_delay=1, max_tries=-1, sleep_func=time.sleep))
self._client.add_listener(self.session_listener)
self._fetch_cluster = True
self._fetch_optime = True
self._orig_kazoo_connect = self._client._connection._connect
self._client._connection._connect = self._kazoo_connect
@@ -100,13 +95,9 @@ class ZooKeeper(AbstractDCS):
if state in [KazooState.SUSPENDED, KazooState.LOST]:
self.cluster_watcher(None)
def optime_watcher(self, event):
self._fetch_optime = True
self.event.set()
def cluster_watcher(self, event):
self._fetch_cluster = True
self.optime_watcher(event)
self.event.set()
def reload_config(self, config):
self.set_retry_timeout(config['retry_timeout'])
@@ -150,12 +141,6 @@ class ZooKeeper(AbstractDCS):
except NoNodeError:
return None
def get_leader_optime(self, leader):
watch = self.optime_watcher if not leader or leader.name != self._name else None
optime = self.get_node(self.leader_optime_path, watch)
self._fetch_optime = False
return optime and int(optime[0]) or 0
@staticmethod
def member(name, value, znode):
return Member.from_node(znode.version, name, znode.ephemeralOwner, value)
@@ -169,7 +154,7 @@ class ZooKeeper(AbstractDCS):
def load_members(self, sync_standby):
members = []
for member in self.get_children(self.members_path, self.cluster_watcher):
watch = member in sync_standby and self.cluster_watcher or None
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))
@@ -193,12 +178,16 @@ class ZooKeeper(AbstractDCS):
history = self.get_node(self.history_path, watch=self.cluster_watcher) if self._HISTORY in nodes else None
history = history and TimelineHistory.from_node(history[1].mzxid, history[0])
# get last leader operation
last_leader_operation = self._OPTIME in nodes and self._fetch_cluster and self.get_node(self.leader_optime_path)
last_leader_operation = last_leader_operation and int(last_leader_operation[0]) or 0
# get synchronization state
sync = self.get_node(self.sync_path, watch=self.cluster_watcher) if self._SYNC in nodes else None
sync = SyncState.from_node(sync and sync[1].version, sync and sync[0])
# get list of members
sync_standby = sync.leader == self._name and sync.members or []
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
@@ -217,9 +206,6 @@ class ZooKeeper(AbstractDCS):
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
self._fetch_cluster = member.index == -1
# get last leader operation
last_leader_operation = self._OPTIME in nodes and self.get_leader_optime(leader)
# 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])
@@ -235,15 +221,6 @@ class ZooKeeper(AbstractDCS):
logger.exception('get_cluster')
self.cluster_watcher(None)
raise ZooKeeperError('ZooKeeper in not responding properly')
# Optime ZNode was updated or doesn't exist and we are not leader
elif (self._fetch_optime and not self._fetch_cluster or not cluster.last_leader_operation) and\
not (cluster.leader and cluster.leader.name == self._name):
try:
optime = self.get_leader_optime(cluster.leader)
cluster = Cluster(cluster.initialize, cluster.config, cluster.leader, optime,
cluster.members, cluster.failover, cluster.sync, cluster.history)
except Exception:
pass
return cluster
def _bypass_caches(self):
@@ -372,6 +349,6 @@ class ZooKeeper(AbstractDCS):
return self.set_sync_state_value("{}", index)
def watch(self, leader_index, timeout):
if super(ZooKeeper, self).watch(leader_index, timeout) and not self._fetch_optime:
if super(ZooKeeper, self).watch(leader_index, timeout):
self._fetch_cluster = True
return self._fetch_cluster
-4
View File
@@ -13,10 +13,6 @@ class PatroniException(Exception):
return repr(self.value)
class PatroniFatalException(PatroniException):
pass
class PostgresException(PatroniException):
pass
+42 -89
View File
@@ -10,7 +10,7 @@ import uuid
from collections import namedtuple
from multiprocessing.pool import ThreadPool
from patroni.async_executor import AsyncExecutor, CriticalTask
from patroni.exceptions import DCSError, PostgresConnectionException, PatroniFatalException
from patroni.exceptions import DCSError, PostgresConnectionException, PatroniException
from patroni.postgresql import ACTION_ON_START, ACTION_ON_ROLE_CHANGE
from patroni.postgresql.misc import postgres_version_to_int
from patroni.postgresql.rewind import Rewind
@@ -70,7 +70,7 @@ class Ha(object):
self._was_paused = False
self._leader_timeline = None
self.recovering = False
self._async_response = CriticalTask()
self._post_bootstrap_task = None
self._crash_recovery_executed = False
self._start_timeout = None
self._async_executor = AsyncExecutor(self.state_handler.cancellable, self.wakeup)
@@ -87,9 +87,6 @@ class Ha(object):
# already running as replica was aborted due to cluster not beeing initialized in DCS.
self._join_aborted = False
# used only in backoff after failing a pre_promote script
self._released_leader_key_timestamp = 0
def check_mode(self, mode):
# Try to protect from the case when DCS was wiped out during pause
if self.cluster and self.cluster.config and self.cluster.config.modify_index:
@@ -255,8 +252,7 @@ class Ha(object):
elif self.cluster.initialize is None and not self.patroni.nofailover and 'bootstrap' in self.patroni.config:
if self.dcs.initialize(create_new=True): # race for initialization
self.state_handler.bootstrapping = True
with self._async_response:
self._async_response.reset()
self._post_bootstrap_task = CriticalTask()
if self.is_standby_cluster():
ret = self._async_executor.try_run_async('bootstrap_standby_leader', self.bootstrap_standby_leader)
@@ -283,8 +279,7 @@ class Ha(object):
clone_source = self.get_remote_master()
msg = 'clone from remote master {0}'.format(clone_source.conn_url)
result = self.clone(clone_source, msg)
with self._async_response: # pretend that post_bootstrap was already executed
self._async_response.complete(result)
self._post_bootstrap_task.complete(result)
if result:
self.state_handler.set_role('standby_leader')
@@ -371,7 +366,7 @@ class Ha(object):
elif self.patroni.replicatefrom and self.patroni.replicatefrom != self.state_handler.name:
node_to_follow = cluster.get_member(self.patroni.replicatefrom)
else:
node_to_follow = cluster.leader if cluster.leader and cluster.leader.name else None
node_to_follow = cluster.leader
node_to_follow = node_to_follow if node_to_follow and node_to_follow.name != self.state_handler.name else None
@@ -452,34 +447,28 @@ class Ha(object):
promoting standbys that were guaranteed to be replicating synchronously.
"""
if self.is_synchronous_mode():
sync_node_count = self.patroni.config['synchronous_node_count']
current = self.cluster.sync.leader and self.cluster.sync.members or []
picked, allow_promote = self.state_handler.pick_synchronous_standby(self.cluster, sync_node_count)
if set(picked) != set(current):
# update synchronous standby list in dcs temporarily to point to common nodes in current and picked
sync_common = list(set(current).intersection(set(allow_promote)))
if set(sync_common) != set(current):
logger.info("Updating synchronous privilege temporarily from %s to %s", current, sync_common)
if not self.dcs.write_sync_state(self.state_handler.name,
sync_common or None,
index=self.cluster.sync.index):
current = self.cluster.sync.leader and self.cluster.sync.sync_standby
picked, allow_promote = self.state_handler.pick_synchronous_standby(self.cluster)
if picked != current:
# We need to revoke privilege from current before replacing it in the config
if current:
logger.info("Removing synchronous privilege from %s", current)
if not self.dcs.write_sync_state(self.state_handler.name, None, index=self.cluster.sync.index):
logger.info('Synchronous replication key updated by someone else.')
return
# Update db param and wait for x secs
if self.is_synchronous_mode_strict() and not picked:
picked = ['*']
if self.is_synchronous_mode_strict() and picked is None:
picked = '*'
logger.warning("No standbys available!")
logger.info("Assigning synchronous standby status to %s", picked)
self.state_handler.config.set_synchronous_standby(picked)
if picked and picked[0] != '*' and set(allow_promote) != set(picked) and not allow_promote:
if picked and picked != '*' and not allow_promote:
# Wait for PostgreSQL to enable synchronous mode and see if we can immediately set sync_standby
time.sleep(2)
_, allow_promote = self.state_handler.pick_synchronous_standby(self.cluster,
sync_node_count)
if allow_promote and set(allow_promote) != set(sync_common):
picked, allow_promote = self.state_handler.pick_synchronous_standby(self.cluster)
if allow_promote:
try:
cluster = self.dcs.get_cluster()
except DCSError:
@@ -487,18 +476,18 @@ class Ha(object):
if cluster.sync.leader and cluster.sync.leader != self.state_handler.name:
logger.info("Synchronous replication key updated by someone else")
return
if not self.dcs.write_sync_state(self.state_handler.name, allow_promote, index=cluster.sync.index):
if not self.dcs.write_sync_state(self.state_handler.name, picked, index=cluster.sync.index):
logger.info("Synchronous replication key updated by someone else")
return
logger.info("Synchronous standby status assigned to %s", allow_promote)
logger.info("Synchronous standby status assigned to %s", picked)
else:
if self.cluster.sync.leader and self.dcs.delete_sync_state(index=self.cluster.sync.index):
logger.info("Disabled synchronous replication")
self.state_handler.config.set_synchronous_standby([])
self.state_handler.config.set_synchronous_standby(None)
def is_sync_standby(self, cluster):
return cluster.leader and cluster.sync.leader == cluster.leader.name \
and self.state_handler.name in cluster.sync.members
and cluster.sync.sync_standby == self.state_handler.name
def while_not_sync_standby(self, func):
"""Runs specified action while trying to make sure that the node is not assigned synchronous standby status.
@@ -561,27 +550,13 @@ class Ha(object):
return self.follow(demote_reason, message)
def enforce_master_role(self, message, promote_message):
"""
Ensure the node that has won the race for the leader key meets criteria
for promoting its PG server to the 'master' role.
"""
if not self.is_paused():
if not self.watchdog.is_running and not self.watchdog.activate():
if self.state_handler.is_leader():
self.demote('immediate')
return 'Demoting self because watchdog could not be activated'
else:
self.release_leader_key_voluntarily()
return 'Not promoting self because watchdog could not be activated'
with self._async_response:
if self._async_response.result is False:
logger.warning("Releasing the leader key voluntarily because the pre-promote script failed")
self._released_leader_key_timestamp = time.time()
self.release_leader_key_voluntarily()
# discard the result of the failed pre-promote script to be able to re-try promote
self._async_response.reset()
return 'Promotion cancelled because the pre-promote script failed'
if not self.is_paused() and not self.watchdog.is_running and not self.watchdog.activate():
if self.state_handler.is_leader():
self.demote('immediate')
return 'Demoting self because watchdog could not be activated'
else:
self.release_leader_key_voluntarily()
return 'Not promoting self because watchdog could not be activated'
if self.state_handler.is_leader():
# Inform the state handler about its master role.
@@ -601,7 +576,7 @@ class Ha(object):
# Somebody else updated sync state, it may be due to us losing the lock. To be safe, postpone
# promotion until next cycle. TODO: trigger immediate retry of run_cycle
return 'Postponing promotion because synchronous replication state was updated by somebody else'
self.state_handler.config.set_synchronous_standby(['*'] if self.is_synchronous_mode_strict() else [])
self.state_handler.config.set_synchronous_standby('*' if self.is_synchronous_mode_strict() else None)
if self.state_handler.role != 'master':
self.set_leader_access_is_restricted(self.cluster.has_permanent_logical_slots(self.state_handler.name))
@@ -609,10 +584,8 @@ class Ha(object):
self._rewind.reset_state()
logger.info("cleared rewind state after becoming the leader")
with self._async_response:
self._async_response.reset()
self._async_executor.try_run_async('promote', self.state_handler.promote,
args=(self.dcs.loop_wait, self._async_response, on_success,
args=(self.dcs.loop_wait, on_success,
self._leader_access_is_restricted))
return promote_message
@@ -748,10 +721,6 @@ class Ha(object):
return self._is_healthiest_node(members, check_replication_lag=False)
def is_healthiest_node(self):
if time.time() - self._released_leader_key_timestamp < self.dcs.ttl:
logger.info('backoff: skip leader race after pre_promote script failure and releasing the lock voluntarily')
return False
if self.is_paused() and not self.patroni.nofailover and \
self.cluster.failover and not self.cluster.failover.scheduled_at:
ret = self.manual_failover_process_no_leader()
@@ -842,7 +811,7 @@ class Ha(object):
self._async_executor.try_run_async('starting after demotion', self.state_handler.follow, (node_to_follow,))
else:
if self.is_synchronous_mode():
self.state_handler.config.set_synchronous_standby([])
self.state_handler.config.set_synchronous_standby(None)
if self._rewind.rewind_or_reinitialize_needed_and_possible(leader):
return False # do not start postgres, but run pg_rewind on the next iteration
self.state_handler.follow(node_to_follow)
@@ -903,7 +872,7 @@ class Ha(object):
else:
if self.is_synchronous_mode():
if failover.candidate and not self.cluster.sync.matches(failover.candidate):
logger.warning('Failover candidate=%s does not match with sync_standbys=%s',
logger.warning('Failover candidate=%s does not match with sync_standby=%s',
failover.candidate, self.cluster.sync.sync_standby)
members = []
else:
@@ -941,9 +910,9 @@ class Ha(object):
self.load_cluster_from_dcs()
if self.is_standby_cluster():
# standby leader disappeared, and this is the healthiest
# standby leader disappeared, and this is a healthiest
# replica, so it should become a new standby leader.
# This implies we need to start following a remote master
# This imply that we need to start following a remote master
msg = 'promoted self to a standby leader by acquiring session lock'
return self.enforce_follow_remote_master(msg)
else:
@@ -1161,20 +1130,10 @@ class Ha(object):
self._async_executor.run_async(self._do_reinitialize, args=(self.cluster, ))
def handle_long_action_in_progress(self):
"""
Figure out what to do with the task AsyncExecutor is performing.
"""
if self.has_lock() and self.update_lock():
return 'updated leader lock during ' + self._async_executor.scheduled_action
elif not self.state_handler.bootstrapping:
# Don't have lock, make sure we are not promoting or starting up a master in the background
if self._async_executor.scheduled_action == 'promote':
with self._async_response:
cancel = self._async_response.cancel()
if cancel:
self.state_handler.cancellable.cancel()
return 'lost leader before promote'
# Don't have lock, make sure we are not starting up a master in the background
if self.state_handler.role == 'master':
logger.info("Demoting master during " + self._async_executor.scheduled_action)
if self._async_executor.scheduled_action == 'restart':
@@ -1185,6 +1144,7 @@ class Ha(object):
self.state_handler.terminate_starting_postmaster(postmaster=task.result)
self.demote('immediate-nolock')
return 'lost leader lock during ' + self._async_executor.scheduled_action
if self.cluster.is_unlocked():
logger.info('not healthy enough for leader race')
@@ -1216,22 +1176,20 @@ class Ha(object):
self.dcs.cancel_initialization()
self.state_handler.stop('immediate', stop_timeout=self.patroni.config['retry_timeout'])
self.state_handler.move_data_directory()
raise PatroniFatalException('Failed to bootstrap cluster')
raise PatroniException('Failed to bootstrap cluster')
def post_bootstrap(self):
with self._async_response:
result = self._async_response.result
# bootstrap has failed if postgres is not running
if not self.state_handler.is_running() or result is False:
if not self.state_handler.is_running() or self._post_bootstrap_task.result is False:
self.cancel_initialization()
if result is None:
if self._post_bootstrap_task.result is None:
if not self.state_handler.is_leader():
return 'waiting for end of recovery after bootstrap'
self.state_handler.set_role('master')
ret = self._async_executor.try_run_async('post_bootstrap', self.state_handler.bootstrap.post_bootstrap,
args=(self.patroni.config['bootstrap'], self._async_response))
args=(self.patroni.config['bootstrap'], self._post_bootstrap_task))
return ret or 'running post_bootstrap'
self.state_handler.bootstrapping = False
@@ -1374,7 +1332,6 @@ class Ha(object):
if not self.state_handler.is_healthy():
if self.is_paused():
self.state_handler.set_state('stopped')
if self.has_lock():
self._delete_leader()
return 'removed leader lock because postgres is not running'
@@ -1387,8 +1344,6 @@ class Ha(object):
(self._rewind.is_needed and self._rewind.can_rewind_or_reinitialize_allowed):
return 'postgres is not running'
if self.state_handler.state in ('running', 'starting'):
self.state_handler.set_state('crashed')
# try to start dead postgres
return self.recover()
@@ -1426,8 +1381,6 @@ class Ha(object):
try:
info = self._run_cycle()
return (self.is_paused() and 'PAUSE: ' or '') + info
except PatroniFatalException:
raise
except Exception:
logger.exception('Unexpected exception')
return 'Unexpected exception raised, please report it as a BUG'
@@ -1445,7 +1398,7 @@ class Ha(object):
self.while_not_sync_standby(lambda: self.state_handler.stop(checkpoint=False, on_safepoint=disable_wd,
stop_timeout=self.master_stop_timeout()))
if not self.state_handler.is_running():
if self.is_leader():
if self.has_lock():
checkpoint_location = self.state_handler.latest_checkpoint_location()
self.dcs.delete_leader(checkpoint_location)
self.touch_member()
@@ -1458,7 +1411,7 @@ class Ha(object):
def watch(self, timeout):
# watch on leader key changes if the postgres is running and leader is known and current node is not lock owner
if self._async_executor.busy or not self.cluster or self.cluster.is_unlocked() or self.has_lock(False):
if self._async_executor.busy or self.cluster.is_unlocked() or self.has_lock(False):
leader_index = None
else:
leader_index = self.cluster.leader.index
+37 -108
View File
@@ -20,8 +20,8 @@ from patroni.postgresql.postmaster import PostmasterProcess
from patroni.postgresql.slots import SlotsHandler
from patroni.exceptions import PostgresConnectionException
from patroni.utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
from psutil import TimeoutExpired
from threading import current_thread, Lock
from psutil import TimeoutExpired
logger = logging.getLogger(__name__)
@@ -470,7 +470,7 @@ class Postgresql(object):
self.config.replace_pg_ident()
options = ['--{0}={1}'.format(p, configuration[p]) for p in self.config.CMDLINE_OPTIONS
if p in configuration and p not in ('wal_keep_segments', 'wal_keep_size')]
if p in configuration and p != 'wal_keep_segments']
if self.cancellable.is_cancelled:
return False
@@ -795,38 +795,9 @@ class Postgresql(object):
if data.get('Database cluster state') == 'in production':
return True
def _pre_promote(self):
"""
Runs a fencing script after the leader lock is acquired but before the replica is promoted.
If the script exits with a non-zero code, promotion does not happen and the leader key is removed from DCS.
"""
cmd = self.config.get('pre_promote')
if not cmd:
return True
ret = self.cancellable.call(shlex.split(cmd))
if ret is not None:
logger.info('pre_promote script `%s` exited with %s', cmd, ret)
return ret == 0
def promote(self, wait_seconds, task, on_success=None, access_is_restricted=False):
def promote(self, wait_seconds, on_success=None, access_is_restricted=False):
if self.role == 'master':
return True
ret = self._pre_promote()
with task:
if task.is_cancelled:
return False
task.complete(ret)
if ret is False:
return False
if self.cancellable.is_cancelled:
logger.info("PostgreSQL promote cancelled.")
return False
ret = self.pg_ctl('promote', '-W')
if ret:
self.set_role('master')
@@ -879,51 +850,10 @@ class Postgresql(object):
self.config.setup_server_parameters()
return True
def pg_wal_realpath(self):
"""Returns a dict containing the symlink (key) and target (value) for the wal directory"""
links = {}
for pg_wal_dir in ('pg_xlog', 'pg_wal'):
pg_wal_path = os.path.join(self._data_dir, pg_wal_dir)
if os.path.exists(pg_wal_path) and os.path.islink(pg_wal_path):
pg_wal_realpath = os.path.realpath(pg_wal_path)
links[pg_wal_path] = pg_wal_realpath
return links
def pg_tblspc_realpaths(self):
"""Returns a dict containing the symlink (key) and target (values) for the tablespaces"""
links = {}
pg_tblsp_dir = os.path.join(self._data_dir, 'pg_tblspc')
if os.path.exists(pg_tblsp_dir):
for tsdn in os.listdir(pg_tblsp_dir):
pg_tsp_path = os.path.join(pg_tblsp_dir, tsdn)
if parse_int(tsdn) and os.path.islink(pg_tsp_path):
pg_tsp_rpath = os.path.realpath(pg_tsp_path)
links[pg_tsp_path] = pg_tsp_rpath
return links
def move_data_directory(self):
if os.path.isdir(self._data_dir) and not self.is_running():
try:
postfix = time.strftime('%Y-%m-%d-%H-%M-%S')
# let's see if the wal directory is a symlink, in this case we
# should move the target
for (source, pg_wal_realpath) in self.pg_wal_realpath().items():
logger.info('renaming WAL directory and updating symlink: %s', pg_wal_realpath)
new_name = '{0}_{1}'.format(pg_wal_realpath, postfix)
os.rename(pg_wal_realpath, new_name)
os.unlink(source)
os.symlink(source, new_name)
# Move user defined tablespace directory
for (source, pg_tsp_rpath) in self.pg_tblspc_realpaths().items():
logger.info('renaming user defined tablespace directory and updating symlink: %s', pg_tsp_rpath)
new_name = '{0}_{1}'.format(pg_tsp_rpath, postfix)
os.rename(pg_tsp_rpath, new_name)
os.unlink(source)
os.symlink(source, new_name)
new_name = '{0}_{1}'.format(self._data_dir, postfix)
new_name = '{0}_{1}'.format(self._data_dir, time.strftime('%Y-%m-%d-%H-%M-%S'))
logger.info('renaming data directory to %s', new_name)
os.rename(self._data_dir, new_name)
except OSError:
@@ -941,62 +871,61 @@ class Postgresql(object):
os.remove(self._data_dir)
elif os.path.isdir(self._data_dir):
# let's see if wal directory is a symlink, in this case we
# let's see if pg_xlog|pg_wal is a symlink, in this case we
# should clean the target
for pg_wal_realpath in self.pg_wal_realpath().values():
logger.info('Removing WAL directory: %s', pg_wal_realpath)
shutil.rmtree(pg_wal_realpath)
# Remove user defined tablespace directories
for pg_tsp_rpath in self.pg_tblspc_realpaths().values():
logger.info('Removing user defined tablespace directory: %s', pg_tsp_rpath)
shutil.rmtree(pg_tsp_rpath, ignore_errors=True)
for pg_wal_dir in ('pg_xlog', 'pg_wal'):
pg_wal_path = os.path.join(self._data_dir, pg_wal_dir)
if os.path.exists(pg_wal_path) and os.path.islink(pg_wal_path):
pg_wal_realpath = os.path.realpath(pg_wal_path)
logger.info('Removing WAL directory: %s', pg_wal_realpath)
shutil.rmtree(pg_wal_realpath)
# Remove user defined tablespace directory
pg_tblsp_dir = os.path.join(self._data_dir, 'pg_tblspc')
if os.path.exists(pg_tblsp_dir):
for tsdn in os.listdir(pg_tblsp_dir):
pg_tsp_path = os.path.join(pg_tblsp_dir, tsdn)
if parse_int(tsdn) and os.path.islink(pg_tsp_path):
pg_tsp_rpath = os.path.realpath(pg_tsp_path)
logger.info('Removing user defined tablespace directory: %s', pg_tsp_rpath)
shutil.rmtree(pg_tsp_rpath, ignore_errors=True)
shutil.rmtree(self._data_dir)
except (IOError, OSError):
logger.exception('Could not remove data directory %s', self._data_dir)
self.move_data_directory()
def _get_synchronous_commit_param(self):
return self.query("SHOW synchronous_commit").fetchone()[0]
def pick_synchronous_standby(self, cluster, sync_node_count=1):
def pick_synchronous_standby(self, cluster):
"""Finds the best candidate to be the synchronous standby.
Current synchronous standby is always preferred, unless it has disconnected or does not want to be a
synchronous standby any longer.
:returns tuple of candidates list and synchronous standby list.
:returns tuple of candidate name or None, and bool showing if the member is the active synchronous standby.
"""
if self._major_version < 90600:
sync_node_count = 1
current = cluster.sync.sync_standby
current = current.lower() if current else current
members = {m.name.lower(): m for m in cluster.members}
candidates = []
sync_nodes = []
# Pick candidates based on who has higher replay/remote_write/flush lsn.
sync_commit_par = self._get_synchronous_commit_param()
sort_col = {'remote_apply': 'replay', 'remote_write': 'write'}.get(sync_commit_par, 'flush')
# pg_stat_replication.sync_state has 4 possible states - async, potential, quorum, sync.
# Sort clause "ORDER BY sync_state DESC" is to get the result in required order and to keep
# the result consistent in case if a synchronous standby member is slowed down OR async node
# receiving changes faster than the sync member (very rare but possible). Such cases would
# trigger sync standby member swapping frequently and the sort on sync_state desc should
# help in keeping the query result consistent.
# Pick candidates based on who has flushed WAL farthest.
# TODO: for synchronous_commit = remote_write we actually want to order on write_location
for app_name, state, sync_state in self.query(
"SELECT pg_catalog.lower(application_name), state, sync_state"
" FROM pg_catalog.pg_stat_replication"
" WHERE state = 'streaming'"
" ORDER BY sync_state DESC, {0}_{1} DESC".format(sort_col, self.lsn_name)):
" ORDER BY flush_{0} DESC".format(self.lsn_name)):
member = members.get(app_name)
if not member or member.tags.get('nosync', False):
if state != 'streaming' or not member or member.tags.get('nosync', False):
continue
candidates.append(member.name)
if sync_state == 'sync':
sync_nodes.append(member.name)
if len(candidates) >= sync_node_count:
break
return member.name, True
if sync_state == 'potential' and app_name == current:
# Prefer current even if not the best one any more to avoid indecisivness and spurious swaps.
return cluster.sync.sync_standby, False
if sync_state in ('async', 'potential'):
candidates.append(member.name)
return candidates, sync_nodes
if candidates:
return candidates[0], False
return None, False
def schedule_sanity_checks_after_pause(self):
"""
+2 -4
View File
@@ -98,8 +98,7 @@ class Bootstrap(object):
def _custom_bootstrap(self, config):
self._postgresql.set_state('running custom bootstrap script')
params = [] if config.get('no_params') else ['--scope=' + self._postgresql.scope,
'--datadir=' + self._postgresql.data_dir]
params = ['--scope=' + self._postgresql.scope, '--datadir=' + self._postgresql.data_dir]
try:
logger.info('Running custom bootstrap script: %s', config['command'])
if self._postgresql.cancellable.call(shlex.split(config['command']) + params) != 0:
@@ -356,8 +355,7 @@ END;$$""".format(f, rewind['username'])
self._running_custom_bootstrap = False
# If we don't have custom configuration for pg_hba.conf we need to restore original file
if not postgresql.config.get('pg_hba'):
if os.path.exists(postgresql.config.pg_hba_conf):
os.unlink(postgresql.config.pg_hba_conf)
os.unlink(postgresql.config.pg_hba_conf)
postgresql.config.restore_configuration_files()
postgresql.config.write_postgresql_conf()
postgresql.config.replace_pg_ident()
-6
View File
@@ -11,10 +11,6 @@ logger = logging.getLogger(__name__)
class CancellableExecutor(object):
"""
There must be only one such process so that AsyncExecutor can easily cancel it.
"""
def __init__(self):
self._process = None
self._process_cmd = None
@@ -119,8 +115,6 @@ class CancellableSubprocess(CancellableExecutor):
self._is_cancelled = True
if self._process is None or not self._process.is_running():
return
logger.info('Terminating %s', self._process_cmd)
self._process.terminate()
for _ in polling_loop(10):
+49 -51
View File
@@ -6,12 +6,11 @@ import socket
import stat
import time
from patroni.exceptions import PatroniException
from six.moves.urllib_parse import urlparse, parse_qsl, unquote
from urllib3.response import HTTPHeaderDict
from .validator import CaseInsensitiveDict, recovery_parameters,\
transform_postgresql_parameter_value, transform_recovery_parameter_value
from ..dcs import slot_name_from_member_name, RemoteMember
from ..exceptions import PatroniFatalException
from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, \
validate_directory, is_subpath
@@ -255,6 +254,21 @@ class ConfigWriter(object):
self.writeline("{0} = '{1}'".format(param, self.escape(value)))
class CaseInsensitiveDict(HTTPHeaderDict):
def add(self, key, val):
self[key] = val
def __getitem__(self, key):
return self._container[key.lower()][1]
def __repr__(self):
return str(dict(self.items()))
def copy(self):
return CaseInsensitiveDict(self._container.values())
class ConfigHandler(object):
# List of parameters which must be always passed to postmaster as command line options
@@ -280,7 +294,6 @@ class ConfigHandler(object):
'max_connections': (100, lambda v: int(v) >= 25, 90100),
'max_wal_senders': (10, lambda v: int(v) >= 3, 90100),
'wal_keep_segments': (8, lambda v: int(v) >= 1, 90100),
'wal_keep_size': ('128MB', lambda v: parse_int(v, 'MB') >= 16, 130000),
'max_prepared_transactions': (0, lambda v: int(v) >= 0, 90100),
'max_locks_per_transaction': (64, lambda v: int(v) >= 32, 90100),
'track_commit_timestamp': ('off', lambda v: parse_bool(v) is not None, 90500),
@@ -289,7 +302,24 @@ class ConfigHandler(object):
'wal_log_hints': ('on', lambda _: False, 90400)
})
_RECOVERY_PARAMETERS = set(recovery_parameters.keys())
_RECOVERY_PARAMETERS = {
'archive_cleanup_command',
'restore_command',
'recovery_end_command',
'recovery_target',
'recovery_target_name',
'recovery_target_time',
'recovery_target_xid',
'recovery_target_lsn',
'recovery_target_inclusive',
'recovery_target_timeline',
'recovery_target_action',
'recovery_min_apply_delay',
'primary_conninfo',
'primary_slot_name',
'promote_trigger_file',
'trigger_file'
}
def __init__(self, postgresql, config):
self._postgresql = postgresql
@@ -309,8 +339,8 @@ class ConfigHandler(object):
self._auto_conf_mtime = None
self._pgpass = os.path.abspath(config.get('pgpass') or os.path.join(os.path.expanduser('~'), 'pgpass'))
if os.path.exists(self._pgpass) and not os.path.isfile(self._pgpass):
raise PatroniFatalException("'{0}' exists and it's not a file, check your `postgresql.pgpass` configuration"
.format(self._pgpass))
raise PatroniException("'{}' exists and it's not a file, check your `postgresql.pgpass` configuration"
.format(self._pgpass))
self._passfile = None
self._passfile_mtime = None
self._synchronous_standby_names = None
@@ -391,9 +421,8 @@ class ConfigHandler(object):
include = self._config.get('custom_conf') or self._postgresql_base_conf_name
f.writeline("include '{0}'\n".format(ConfigWriter.escape(include)))
for name, value in sorted((configuration or self._server_parameters).items()):
value = transform_postgresql_parameter_value(self._postgresql.major_version, name, value)
if (not self._postgresql.bootstrap.running_custom_bootstrap or name != 'hba_file') \
and name not in self._RECOVERY_PARAMETERS and value is not None:
and name not in self._RECOVERY_PARAMETERS:
f.write_param(name, value)
# when we are doing custom bootstrap we assume that we don't know superuser password
# and in order to be able to change it, we are opening trust access from a certain address
@@ -496,13 +525,6 @@ class ConfigHandler(object):
if kw not in skip and params.get(kw) is not None)
def _write_recovery_params(self, fd, recovery_params):
if self._postgresql.major_version >= 90500:
pause_at_recovery_target = parse_bool(recovery_params.pop('pause_at_recovery_target', None))
if pause_at_recovery_target is not None:
recovery_params.setdefault('recovery_target_action', 'pause' if pause_at_recovery_target else 'promote')
else:
if str(recovery_params.pop('recovery_target_action', None)).lower() == 'promote':
recovery_params.setdefault('pause_at_recovery_target', 'false')
for name, value in sorted(recovery_params.items()):
if name == 'primary_conninfo':
if 'password' in value and self._postgresql.major_version >= 100000:
@@ -510,10 +532,6 @@ class ConfigHandler(object):
value['passfile'] = self._passfile = self._pgpass
self._passfile_mtime = mtime(self._pgpass)
value = self.format_dsn(value)
else:
value = transform_recovery_parameter_value(self._postgresql.major_version, name, value)
if value is None:
continue
fd.write_param(name, value)
def build_recovery_params(self, member):
@@ -557,8 +575,7 @@ class ConfigHandler(object):
@property
def _recovery_parameters_to_compare(self):
skip_params = {'pause_at_recovery_target', 'recovery_target_inclusive',
'recovery_target_action', 'standby_mode', self._triggerfile_wrong_name}
skip_params = {'recovery_target_inclusive', 'recovery_target_action', self._triggerfile_wrong_name}
return self._RECOVERY_PARAMETERS - skip_params
def _read_recovery_params(self):
@@ -712,9 +729,6 @@ class ConfigHandler(object):
if param == 'recovery_min_apply_delay':
if not compare_values('integer', 'ms', value[0], wanted_recovery_params.get(param, 0)):
record_missmatch(value[1])
elif param == 'standby_mode':
if not compare_values('bool', None, value[0], wanted_recovery_params.get(param, 'on')):
record_missmatch(value[1])
elif param == 'primary_conninfo':
if not self._check_primary_conninfo(value[0], wanted_recovery_params.get('primary_conninfo', {})):
record_missmatch(value[1])
@@ -734,7 +748,7 @@ class ConfigHandler(object):
def escape(value):
return re.sub(r'([:\\])', r'\\\1', str(value))
record = {n: escape(record.get(n) or '*') for n in ('host', 'port', 'user', 'password')}
record = {n: escape(record.get(n, '*')) for n in ('host', 'port', 'user', 'password')}
return '{host}:{port}:*:{user}:{password}'.format(**record)
def write_pgpass(self, record):
@@ -816,20 +830,8 @@ class ConfigHandler(object):
parameters.pop('synchronous_standby_names', None)
else:
parameters['synchronous_standby_names'] = self._synchronous_standby_names
# Handle hot_standby <-> replica rename
if parameters.get('wal_level') == ('hot_standby' if self._postgresql.major_version >= 90600 else 'replica'):
parameters['wal_level'] = 'replica' if self._postgresql.major_version >= 90600 else 'hot_standby'
# Try to recalcualte wal_keep_segments <-> wal_keep_size assuming that typical wal_segment_size is 16MB.
# The real segment size could be estimated from pg_control, but we don't really care, because the only goal of
# this exercise is improving cross version compatibility and user must set the correct parameter in the config.
if self._postgresql.major_version >= 130000:
wal_keep_segments = parameters.pop('wal_keep_segments', self.CMDLINE_OPTIONS['wal_keep_segments'][0])
parameters.setdefault('wal_keep_size', str(wal_keep_segments * 16) + 'MB')
elif self._postgresql.major_version:
wal_keep_size = parse_int(parameters.pop('wal_keep_size', self.CMDLINE_OPTIONS['wal_keep_size'][0]), 'MB')
parameters.setdefault('wal_keep_segments', int((wal_keep_size + 8) / 16))
if self._postgresql.major_version >= 90600 and parameters['wal_level'] == 'hot_standby':
parameters['wal_level'] = 'replica'
ret = CaseInsensitiveDict({k: v for k, v in parameters.items() if not self._postgresql.major_version or
self._postgresql.major_version >= self.CMDLINE_OPTIONS.get(k, (0, 1, 90100))[2]})
ret.update({k: os.path.join(self._config_dir, ret[k]) for k in ('hba_file', 'ident_file') if k in ret})
@@ -1005,20 +1007,16 @@ class ConfigHandler(object):
else:
logger.info('No PostgreSQL configuration items changed, nothing to reload.')
def set_synchronous_standby(self, sync_members):
def set_synchronous_standby(self, name):
"""Sets a node to be synchronous standby and if changed does a reload for PostgreSQL."""
if sync_members and sync_members != ['*']:
sync_members = [quote_ident(x) for x in sync_members]
if self._postgresql.major_version >= 90600 and len(sync_members) > 1:
sync_param = '{0} ({1})'.format(len(sync_members), ','.join(sync_members))
else:
sync_param = next(iter(sync_members), None)
if sync_param != self._synchronous_standby_names:
if sync_param is None:
if name and name != '*':
name = quote_ident(name)
if name != self._synchronous_standby_names:
if name is None:
self._server_parameters.pop('synchronous_standby_names', None)
else:
self._server_parameters['synchronous_standby_names'] = sync_param
self._synchronous_standby_names = sync_param
self._server_parameters['synchronous_standby_names'] = name
self._synchronous_standby_names = name
if self._postgresql.state == 'running':
self.write_postgresql_conf()
self._postgresql.reload()
+3 -3
View File
@@ -283,9 +283,9 @@ class Rewind(object):
if b > -1:
b += len(pattern)
e = line.find('": ', b)
if e > -1 and '/' in line[b:e]:
waldir, wal_filename = line[b:e].rsplit('/', 1)
if waldir.endswith('/pg_' + self._postgresql.wal_name) and len(wal_filename) == 24:
if e > -1:
waldir, wal_filename = os.path.split(line[b:e])
if waldir.endswith(os.path.sep + 'pg_' + self._postgresql.wal_name) and len(wal_filename) == 24:
return wal_filename
def pg_rewind(self, r):
-518
View File
@@ -1,518 +0,0 @@
import abc
import logging
import six
from collections import namedtuple
from urllib3.response import HTTPHeaderDict
from ..utils import parse_bool, parse_int, parse_real
logger = logging.getLogger(__name__)
class CaseInsensitiveDict(HTTPHeaderDict):
def add(self, key, val):
self[key] = val
def __getitem__(self, key):
return self._container[key.lower()][1]
def __repr__(self):
return str(dict(self.items()))
def copy(self):
return CaseInsensitiveDict(self._container.values())
class Bool(namedtuple('Bool', 'version_from,version_till')):
@staticmethod
def transform(name, value):
if parse_bool(value) is not None:
return value
logger.warning('Removing bool parameter=%s from the config due to the invalid value=%s', name, value)
@six.add_metaclass(abc.ABCMeta)
class Number(namedtuple('Number', 'version_from,version_till,min_val,max_val,unit')):
@staticmethod
@abc.abstractmethod
def parse(value, unit):
"""parse value"""
def transform(self, name, value):
num_value = self.parse(value, self.unit)
if num_value is not None:
if num_value < self.min_val:
logger.warning('Value=%s of parameter=%s is too low, increasing to %s%s',
value, name, self.min_val, self.unit or '')
return self.min_val
if num_value > self.max_val:
logger.warning('Value=%s of parameter=%s is too big, decreasing to %s%s',
value, name, self.max_val, self.unit or '')
return self.max_val
return value
logger.warning('Removing %s parameter=%s from the config due to the invalid value=%s',
self.__class__.__name__.lower(), name, value)
class Integer(Number):
@staticmethod
def parse(value, unit):
return parse_int(value, unit)
class Real(Number):
@staticmethod
def parse(value, unit):
return parse_real(value, unit)
class Enum(namedtuple('Enum', 'version_from,version_till,possible_values')):
def transform(self, name, value):
if str(value).lower() in self.possible_values:
return value
logger.warning('Removing enum parameter=%s from the config due to the invalid value=%s', name, value)
class EnumBool(Enum):
def transform(self, name, value):
if parse_bool(value) is not None:
return value
return super(EnumBool, self).transform(name, value)
class String(namedtuple('String', 'version_from,version_till')):
@staticmethod
def transform(name, value):
return value
# Format:
# key - parameter name
# value - tuple or multiple tuples if something was changing in GUC across postgres versions
parameters = CaseInsensitiveDict({
'allow_system_table_mods': Bool(90300, None),
'application_name': String(90300, None),
'archive_command': String(90300, None),
'archive_mode': (
Bool(90300, 90500),
EnumBool(90500, None, ('always',))
),
'archive_timeout': Integer(90300, None, 0, 1073741823, 's'),
'array_nulls': Bool(90300, None),
'authentication_timeout': Integer(90300, None, 1, 600, 's'),
'autovacuum': Bool(90300, None),
'autovacuum_analyze_scale_factor': Real(90300, None, 0, 100, None),
'autovacuum_analyze_threshold': Integer(90300, None, 0, 2147483647, None),
'autovacuum_freeze_max_age': Integer(90300, None, 100000, 2000000000, None),
'autovacuum_max_workers': (
Integer(90300, 90600, 1, 8388607, None),
Integer(90600, None, 1, 262143, None)
),
'autovacuum_multixact_freeze_max_age': Integer(90300, None, 10000, 2000000000, None),
'autovacuum_naptime': Integer(90300, None, 1, 2147483, 's'),
'autovacuum_vacuum_cost_delay': (
Integer(90300, 120000, -1, 100, 'ms'),
Real(120000, None, -1, 100, 'ms')
),
'autovacuum_vacuum_cost_limit': Integer(90300, None, -1, 10000, None),
'autovacuum_vacuum_insert_scale_factor': Real(130000, None, 0, 100, None),
'autovacuum_vacuum_insert_threshold': Integer(130000, None, -1, 2147483647, None),
'autovacuum_vacuum_scale_factor': Real(90300, None, 0, 100, None),
'autovacuum_vacuum_threshold': Integer(90300, None, 0, 2147483647, None),
'autovacuum_work_mem': Integer(90400, None, -1, 2147483647, 'kB'),
'backend_flush_after': Integer(90600, None, 0, 256, '8kB'),
'backslash_quote': EnumBool(90300, None, ('safe_encoding',)),
'backtrace_functions': String(130000, None),
'bgwriter_delay': Integer(90300, None, 10, 10000, 'ms'),
'bgwriter_flush_after': Integer(90600, None, 0, 256, '8kB'),
'bgwriter_lru_maxpages': (
Integer(90300, 100000, 0, 1000, None),
Integer(100000, None, 0, 1073741823, None)
),
'bgwriter_lru_multiplier': Real(90300, None, 0, 10, None),
'bonjour': Bool(90300, None),
'bonjour_name': String(90300, None),
'bytea_output': Enum(90300, None, ('escape', 'hex')),
'check_function_bodies': Bool(90300, None),
'checkpoint_completion_target': Real(90300, None, 0, 1, None),
'checkpoint_flush_after': Integer(90600, None, 0, 256, '8kB'),
'checkpoint_segments': Integer(90300, 90500, 1, 2147483647, None),
'checkpoint_timeout': (
Integer(90300, 90600, 30, 3600, 's'),
Integer(90600, None, 30, 86400, 's')
),
'checkpoint_warning': Integer(90300, None, 0, 2147483647, 's'),
'client_encoding': String(90300, None),
'client_min_messages': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2',
'debug1', 'log', 'notice', 'warning', 'error')),
'cluster_name': String(90500, None),
'commit_delay': Integer(90300, None, 0, 100000, None),
'commit_siblings': Integer(90300, None, 0, 1000, None),
'config_file': String(90300, None),
'constraint_exclusion': EnumBool(90300, None, ('partition',)),
'cpu_index_tuple_cost': Real(90300, None, 0, 1.79769e+308, None),
'cpu_operator_cost': Real(90300, None, 0, 1.79769e+308, None),
'cpu_tuple_cost': Real(90300, None, 0, 1.79769e+308, None),
'cursor_tuple_fraction': Real(90300, None, 0, 1, None),
'data_directory': String(90300, None),
'data_sync_retry': Bool(90400, None),
'DateStyle': String(90300, None),
'db_user_namespace': Bool(90300, None),
'deadlock_timeout': Integer(90300, None, 1, 2147483647, 'ms'),
'debug_pretty_print': Bool(90300, None),
'debug_print_parse': Bool(90300, None),
'debug_print_plan': Bool(90300, None),
'debug_print_rewritten': Bool(90300, None),
'default_statistics_target': Integer(90300, None, 1, 10000, None),
'default_table_access_method': String(120000, None),
'default_tablespace': String(90300, None),
'default_text_search_config': String(90300, None),
'default_transaction_deferrable': Bool(90300, None),
'default_transaction_isolation': Enum(90300, None, ('serializable', 'repeatable read',
'read committed', 'read uncommitted')),
'default_transaction_read_only': Bool(90300, None),
'default_with_oids': Bool(90300, 120000),
'dynamic_library_path': String(90300, None),
'dynamic_shared_memory_type': (
Enum(90400, 120000, ('posix', 'sysv', 'mmap', 'none')),
Enum(120000, None, ('posix', 'sysv', 'mmap'))
),
'effective_cache_size': Integer(90300, None, 1, 2147483647, '8kB'),
'effective_io_concurrency': Integer(90300, None, 0, 1000, None),
'enable_bitmapscan': Bool(90300, None),
'enable_gathermerge': Bool(100000, None),
'enable_hashagg': Bool(90300, None),
'enable_hashjoin': Bool(90300, None),
'enable_incremental_sort': Bool(130000, None),
'enable_indexonlyscan': Bool(90300, None),
'enable_indexscan': Bool(90300, None),
'enable_material': Bool(90300, None),
'enable_mergejoin': Bool(90300, None),
'enable_nestloop': Bool(90300, None),
'enable_parallel_append': Bool(110000, None),
'enable_parallel_hash': Bool(110000, None),
'enable_partition_pruning': Bool(110000, None),
'enable_partitionwise_aggregate': Bool(110000, None),
'enable_partitionwise_join': Bool(110000, None),
'enable_seqscan': Bool(90300, None),
'enable_sort': Bool(90300, None),
'enable_tidscan': Bool(90300, None),
'escape_string_warning': Bool(90300, None),
'event_source': String(90300, None),
'exit_on_error': Bool(90300, None),
'external_pid_file': String(90300, None),
'extra_float_digits': Integer(90300, None, -15, 3, None),
'force_parallel_mode': EnumBool(90600, None, ('regress',)),
'from_collapse_limit': Integer(90300, None, 1, 2147483647, None),
'fsync': Bool(90300, None),
'full_page_writes': Bool(90300, None),
'geqo': Bool(90300, None),
'geqo_effort': Integer(90300, None, 1, 10, None),
'geqo_generations': Integer(90300, None, 0, 2147483647, None),
'geqo_pool_size': Integer(90300, None, 0, 2147483647, None),
'geqo_seed': Real(90300, None, 0, 1, None),
'geqo_selection_bias': Real(90300, None, 1.5, 2, None),
'geqo_threshold': Integer(90300, None, 2, 2147483647, None),
'gin_fuzzy_search_limit': Integer(90300, None, 0, 2147483647, None),
'gin_pending_list_limit': Integer(90500, None, 64, 2147483647, 'kB'),
'hash_mem_multiplier': Real(130000, None, 1, 1000, None),
'hba_file': String(90300, None),
'hot_standby': Bool(90300, None),
'hot_standby_feedback': Bool(90300, None),
'huge_pages': EnumBool(90400, None, ('try',)),
'ident_file': String(90300, None),
'idle_in_transaction_session_timeout': Integer(90600, None, 0, 2147483647, 'ms'),
'ignore_checksum_failure': Bool(90300, None),
'ignore_invalid_pages': Bool(130000, None),
'ignore_system_indexes': Bool(90300, None),
'IntervalStyle': Enum(90300, None, ('postgres', 'postgres_verbose', 'sql_standard', 'iso_8601')),
'jit': Bool(110000, None),
'jit_above_cost': Real(110000, None, -1, 1.79769e+308, None),
'jit_debugging_support': Bool(110000, None),
'jit_dump_bitcode': Bool(110000, None),
'jit_expressions': Bool(110000, None),
'jit_inline_above_cost': Real(110000, None, -1, 1.79769e+308, None),
'jit_optimize_above_cost': Real(110000, None, -1, 1.79769e+308, None),
'jit_profiling_support': Bool(110000, None),
'jit_provider': String(110000, None),
'jit_tuple_deforming': Bool(110000, None),
'join_collapse_limit': Integer(90300, None, 1, 2147483647, None),
'krb_caseins_users': Bool(90300, None),
'krb_server_keyfile': String(90300, None),
'krb_srvname': String(90300, 90400),
'lc_messages': String(90300, None),
'lc_monetary': String(90300, None),
'lc_numeric': String(90300, None),
'lc_time': String(90300, None),
'listen_addresses': String(90300, None),
'local_preload_libraries': String(90300, None),
'lock_timeout': Integer(90300, None, 0, 2147483647, 'ms'),
'lo_compat_privileges': Bool(90300, None),
'log_autovacuum_min_duration': Integer(90300, None, -1, 2147483647, 'ms'),
'log_checkpoints': Bool(90300, None),
'log_connections': Bool(90300, None),
'log_destination': String(90300, None),
'log_directory': String(90300, None),
'log_disconnections': Bool(90300, None),
'log_duration': Bool(90300, None),
'log_error_verbosity': Enum(90300, None, ('terse', 'default', 'verbose')),
'log_executor_stats': Bool(90300, None),
'log_file_mode': Integer(90300, None, 0, 511, None),
'log_filename': String(90300, None),
'logging_collector': Bool(90300, None),
'log_hostname': Bool(90300, None),
'logical_decoding_work_mem': Integer(130000, None, 64, 2147483647, 'kB'),
'log_line_prefix': String(90300, None),
'log_lock_waits': Bool(90300, None),
'log_min_duration_sample': Integer(130000, None, -1, 2147483647, 'ms'),
'log_min_duration_statement': Integer(90300, None, -1, 2147483647, 'ms'),
'log_min_error_statement': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2', 'debug1', 'info',
'notice', 'warning', 'error', 'log', 'fatal', 'panic')),
'log_min_messages': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2', 'debug1', 'info',
'notice', 'warning', 'error', 'log', 'fatal', 'panic')),
'log_parameter_max_length': Integer(130000, None, -1, 1073741823, 'B'),
'log_parameter_max_length_on_error': Integer(130000, None, -1, 1073741823, 'B'),
'log_parser_stats': Bool(90300, None),
'log_planner_stats': Bool(90300, None),
'log_replication_commands': Bool(90500, None),
'log_rotation_age': Integer(90300, None, 0, 35791394, 'min'),
'log_rotation_size': Integer(90300, None, 0, 2097151, 'kB'),
'log_statement': Enum(90300, None, ('none', 'ddl', 'mod', 'all')),
'log_statement_sample_rate': Real(130000, None, 0, 1, None),
'log_statement_stats': Bool(90300, None),
'log_temp_files': Integer(90300, None, -1, 2147483647, 'kB'),
'log_timezone': String(90300, None),
'log_transaction_sample_rate': Real(120000, None, 0, 1, None),
'log_truncate_on_rotation': Bool(90300, None),
'maintenance_io_concurrency': Integer(130000, None, 0, 1000, None),
'maintenance_work_mem': Integer(90300, None, 1024, 2147483647, 'kB'),
'max_connections': (
Integer(90300, 90600, 1, 8388607, None),
Integer(90600, None, 1, 262143, None)
),
'max_files_per_process': (
Integer(90300, 130000, 25, 2147483647, None),
Integer(130000, None, 64, 2147483647, None)
),
'max_locks_per_transaction': Integer(90300, None, 10, 2147483647, None),
'max_logical_replication_workers': Integer(100000, None, 0, 262143, None),
'max_parallel_maintenance_workers': Integer(110000, None, 0, 1024, None),
'max_parallel_workers': Integer(100000, None, 0, 1024, None),
'max_parallel_workers_per_gather': Integer(90600, None, 0, 1024, None),
'max_pred_locks_per_page': Integer(100000, None, 0, 2147483647, None),
'max_pred_locks_per_relation': Integer(100000, None, -2147483648, 2147483647, None),
'max_pred_locks_per_transaction': Integer(90300, None, 10, 2147483647, None),
'max_prepared_transactions': (
Integer(90300, 90600, 0, 8388607, None),
Integer(90600, None, 0, 262143, None)
),
'max_replication_slots': (
Integer(90400, 90600, 0, 8388607, None),
Integer(90600, None, 0, 262143, None)
),
'max_slot_wal_keep_size': Integer(130000, None, -1, 2147483647, 'MB'),
'max_stack_depth': Integer(90300, None, 100, 2147483647, 'kB'),
'max_standby_archive_delay': Integer(90300, None, -1, 2147483647, 'ms'),
'max_standby_streaming_delay': Integer(90300, None, -1, 2147483647, 'ms'),
'max_sync_workers_per_subscription': Integer(100000, None, 0, 262143, None),
'max_wal_senders': (
Integer(90300, 90600, 0, 8388607, None),
Integer(90600, None, 0, 262143, None)
),
'max_wal_size': (
Integer(90500, 100000, 2, 2147483647, '16MB'),
Integer(100000, None, 2, 2147483647, 'MB')
),
'max_worker_processes': (
Integer(90400, 90600, 1, 8388607, None),
Integer(90600, None, 0, 262143, None)
),
'min_parallel_index_scan_size': Integer(100000, None, 0, 715827882, '8kB'),
'min_parallel_relation_size': Integer(90600, 100000, 0, 715827882, '8kB'),
'min_parallel_table_scan_size': Integer(100000, None, 0, 715827882, '8kB'),
'min_wal_size': (
Integer(90500, 100000, 2, 2147483647, '16MB'),
Integer(100000, None, 2, 2147483647, 'MB')
),
'old_snapshot_threshold': Integer(90600, None, -1, 86400, 'min'),
'operator_precedence_warning': Bool(90500, None),
'parallel_leader_participation': Bool(110000, None),
'parallel_setup_cost': Real(90600, None, 0, 1.79769e+308, None),
'parallel_tuple_cost': Real(90600, None, 0, 1.79769e+308, None),
'password_encryption': (
Bool(90300, 100000),
Enum(100000, None, ('md5', 'scram-sha-256'))
),
'plan_cache_mode': Enum(120000, None, ('auto', 'force_generic_plan', 'force_custom_plan')),
'port': Integer(90300, None, 1, 65535, None),
'post_auth_delay': Integer(90300, None, 0, 2147, 's'),
'pre_auth_delay': Integer(90300, None, 0, 60, 's'),
'quote_all_identifiers': Bool(90300, None),
'random_page_cost': Real(90300, None, 0, 1.79769e+308, None),
'replacement_sort_tuples': Integer(90600, 110000, 0, 2147483647, None),
'restart_after_crash': Bool(90300, None),
'row_security': Bool(90500, None),
'search_path': String(90300, None),
'seq_page_cost': Real(90300, None, 0, 1.79769e+308, None),
'session_preload_libraries': String(90400, None),
'session_replication_role': Enum(90300, None, ('origin', 'replica', 'local')),
'shared_buffers': Integer(90300, None, 16, 1073741823, '8kB'),
'shared_memory_type': Enum(120000, None, ('sysv', 'mmap')),
'shared_preload_libraries': String(90300, None),
'sql_inheritance': Bool(90300, 100000),
'ssl': Bool(90300, None),
'ssl_ca_file': String(90300, None),
'ssl_cert_file': String(90300, None),
'ssl_ciphers': String(90300, None),
'ssl_crl_file': String(90300, None),
'ssl_dh_params_file': String(100000, None),
'ssl_ecdh_curve': String(90400, None),
'ssl_key_file': String(90300, None),
'ssl_max_protocol_version': Enum(120000, None, ('', 'tlsv1', 'tlsv1.1', 'tlsv1.2', 'tlsv1.3')),
'ssl_min_protocol_version': Enum(120000, None, ('tlsv1', 'tlsv1.1', 'tlsv1.2', 'tlsv1.3')),
'ssl_passphrase_command': String(110000, None),
'ssl_passphrase_command_supports_reload': Bool(110000, None),
'ssl_prefer_server_ciphers': Bool(90400, None),
'ssl_renegotiation_limit': Integer(90300, 90500, 0, 2147483647, 'kB'),
'standard_conforming_strings': Bool(90300, None),
'statement_timeout': Integer(90300, None, 0, 2147483647, 'ms'),
'stats_temp_directory': String(90300, None),
'superuser_reserved_connections': (
Integer(90300, 90600, 0, 8388607, None),
Integer(90600, None, 0, 262143, None),
),
'synchronize_seqscans': Bool(90300, None),
'synchronous_commit': (
EnumBool(90300, 90600, ('local', 'remote_write')),
EnumBool(90600, None, ('local', 'remote_write', 'remote_apply'))
),
'synchronous_standby_names': String(90300, None),
'syslog_facility': Enum(90300, None, ('local0', 'local1', 'local2', 'local3',
'local4', 'local5', 'local6', 'local7')),
'syslog_ident': String(90300, None),
'syslog_sequence_numbers': Bool(90600, None),
'syslog_split_messages': Bool(90600, None),
'tcp_keepalives_count': Integer(90300, None, 0, 2147483647, None),
'tcp_keepalives_idle': Integer(90300, None, 0, 2147483647, 's'),
'tcp_keepalives_interval': Integer(90300, None, 0, 2147483647, 's'),
'tcp_user_timeout': Integer(120000, None, 0, 2147483647, 'ms'),
'temp_buffers': Integer(90300, None, 100, 1073741823, '8kB'),
'temp_file_limit': Integer(90300, None, -1, 2147483647, 'kB'),
'temp_tablespaces': String(90300, None),
'TimeZone': String(90300, None),
'timezone_abbreviations': String(90300, None),
'trace_notify': Bool(90300, None),
'trace_recovery_messages': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2',
'debug1', 'log', 'notice', 'warning', 'error')),
'trace_sort': Bool(90300, None),
'track_activities': Bool(90300, None),
'track_activity_query_size': (
Integer(90300, 110000, 100, 102400, None),
Integer(110000, 130000, 100, 102400, 'B'),
Integer(130000, None, 100, 1048576, 'B')
),
'track_commit_timestamp': Bool(90500, None),
'track_counts': Bool(90300, None),
'track_functions': Enum(90300, None, ('none', 'pl', 'all')),
'track_io_timing': Bool(90300, None),
'transaction_deferrable': Bool(90300, None),
'transaction_isolation': Enum(90300, None, ('serializable', 'repeatable read',
'read committed', 'read uncommitted')),
'transaction_read_only': Bool(90300, None),
'transform_null_equals': Bool(90300, None),
'unix_socket_directories': String(90300, None),
'unix_socket_group': String(90300, None),
'unix_socket_permissions': Integer(90300, None, 0, 511, None),
'update_process_title': Bool(90300, None),
'vacuum_cleanup_index_scale_factor': Real(110000, None, 0, 1e+10, None),
'vacuum_cost_delay': (
Integer(90300, 120000, 0, 100, 'ms'),
Real(120000, None, 0, 100, 'ms')
),
'vacuum_cost_limit': Integer(90300, None, 1, 10000, None),
'vacuum_cost_page_dirty': Integer(90300, None, 0, 10000, None),
'vacuum_cost_page_hit': Integer(90300, None, 0, 10000, None),
'vacuum_cost_page_miss': Integer(90300, None, 0, 10000, None),
'vacuum_defer_cleanup_age': Integer(90300, None, 0, 1000000, None),
'vacuum_freeze_min_age': Integer(90300, None, 0, 1000000000, None),
'vacuum_freeze_table_age': Integer(90300, None, 0, 2000000000, None),
'vacuum_multixact_freeze_min_age': Integer(90300, None, 0, 1000000000, None),
'vacuum_multixact_freeze_table_age': Integer(90300, None, 0, 2000000000, None),
'wal_buffers': Integer(90300, None, -1, 262143, '8kB'),
'wal_compression': Bool(90500, None),
'wal_consistency_checking': String(100000, None),
'wal_init_zero': Bool(120000, None),
'wal_keep_segments': Integer(90300, 130000, 0, 2147483647, None),
'wal_keep_size': Integer(130000, None, 0, 2147483647, 'MB'),
'wal_level': (
Enum(90300, 90400, ('minimal', 'archive', 'hot_standby')),
Enum(90400, 90600, ('minimal', 'archive', 'hot_standby', 'logical')),
Enum(90600, None, ('minimal', 'replica', 'logical'))
),
'wal_log_hints': Bool(90400, None),
'wal_receiver_create_temp_slot': Bool(130000, None),
'wal_receiver_status_interval': Integer(90300, None, 0, 2147483, 's'),
'wal_receiver_timeout': Integer(90300, None, 0, 2147483647, 'ms'),
'wal_recycle': Bool(120000, None),
'wal_retrieve_retry_interval': Integer(90500, None, 1, 2147483647, 'ms'),
'wal_sender_timeout': Integer(90300, None, 0, 2147483647, 'ms'),
'wal_skip_threshold': Integer(130000, None, 0, 2147483647, 'kB'),
'wal_sync_method': Enum(90300, None, ('fsync', 'fdatasync', 'open_sync', 'open_datasync')),
'wal_writer_delay': Integer(90300, None, 1, 10000, 'ms'),
'wal_writer_flush_after': Integer(90600, None, 0, 2147483647, '8kB'),
'work_mem': Integer(90300, None, 64, 2147483647, 'kB'),
'xmlbinary': Enum(90300, None, ('base64', 'hex')),
'xmloption': Enum(90300, None, ('content', 'document')),
'zero_damaged_pages': Bool(90300, None)
})
recovery_parameters = CaseInsensitiveDict({
'archive_cleanup_command': String(90300, None),
'pause_at_recovery_target': Bool(90300, 90500),
'primary_conninfo': String(90300, None),
'primary_slot_name': String(90400, None),
'promote_trigger_file': String(120000, None),
'recovery_end_command': String(90300, None),
'recovery_min_apply_delay': Integer(90400, None, 0, 2147483647, 'ms'),
'recovery_target': Enum(90400, None, ('immediate',)),
'recovery_target_action': Enum(90500, None, ('pause', 'promote', 'shutdown')),
'recovery_target_inclusive': Bool(90300, None),
'recovery_target_lsn': String(100000, None),
'recovery_target_name': String(90400, None),
'recovery_target_time': String(90300, None),
'recovery_target_timeline': String(90300, None),
'recovery_target_xid': String(90300, None),
'restore_command': String(90300, None),
'standby_mode': Bool(90300, 120000),
'trigger_file': String(90300, 120000)
})
def _transform_parameter_value(validators, version, name, value):
validators = validators.get(name)
if validators:
for validator in (validators if isinstance(validators[0], tuple) else [validators]):
if version >= validator.version_from and\
(validator.version_till is None or version < validator.version_till):
return validator.transform(name, value)
logger.warning('Removing unexpected parameter=%s value=%s from the config', name, value)
def transform_postgresql_parameter_value(version, name, value):
if '.' in name:
return value
return _transform_parameter_value(parameters, version, name, value)
def transform_recovery_parameter_value(version, name, value):
return _transform_parameter_value(recovery_parameters, version, name, value)
-34
View File
@@ -1,34 +0,0 @@
import logging
import os
from patroni.daemon import AbstractPatroniDaemon, abstract_main
from patroni.dcs.raft import KVStoreTTL
from pysyncobj import SyncObjConf
logger = logging.getLogger(__name__)
class RaftController(AbstractPatroniDaemon):
def __init__(self, config):
super(RaftController, self).__init__(config)
raft_config = self.config.get('raft')
self_addr = raft_config['self_addr']
template = os.path.join(raft_config.get('data_dir', ''), self_addr)
self._syncobj_config = SyncObjConf(autoTick=False, appendEntriesUseBatch=False, dynamicMembershipChange=True,
journalFile=template + '.journal', fullDumpFile=template + '.dump')
self._raft = KVStoreTTL(self_addr, raft_config.get('partner_addrs', []), self._syncobj_config)
def _run_cycle(self):
try:
self._raft.doTick(self._syncobj_config.autoTickPeriod)
except Exception:
logger.exception('doTick')
def _shutdown(self):
self._raft.destroy()
def main():
abstract_main(RaftController)
+1 -60
View File
@@ -1,11 +1,8 @@
import json.decoder as json_decoder
import logging
import os
import platform
import random
import re
import socket
import sys
import tempfile
import time
@@ -376,27 +373,6 @@ def uri(proto, netloc, path='', user=None):
return '{0}://{1}{2}{3}{4}'.format(proto, user, host, port, path)
def iter_response_objects(response):
prev = ''
decoder = json_decoder.JSONDecoder()
for chunk in response.read_chunked(decode_content=False):
if isinstance(chunk, bytes):
chunk = chunk.decode('utf-8')
chunk = prev + chunk
length = len(chunk)
idx = json_decoder.WHITESPACE.match(chunk, 0).end()
while idx < length:
try:
message, idx = decoder.raw_decode(chunk, idx)
except ValueError: # malformed or incomplete JSON, unlikely to happen
break
else:
yield message
idx = json_decoder.WHITESPACE.match(chunk, idx).end()
prev = chunk[idx:]
def is_standby_cluster(config):
# Check whether or not provided configuration describes a standby cluster
return isinstance(config, dict) and (config.get('host') or config.get('port') or config.get('restore_command'))
@@ -411,7 +387,7 @@ def cluster_as_json(cluster):
if m.name == leader_name:
config = cluster.config.data if cluster.config and cluster.config.modify_index else {}
role = 'standby_leader' if is_standby_cluster(config.get('standby_cluster')) else 'leader'
elif m.name in cluster.sync.members:
elif m.name == cluster.sync.sync_standby:
role = 'sync_standby'
else:
role = 'replica'
@@ -477,38 +453,3 @@ def data_directory_is_empty(data_dir):
if not os.path.exists(data_dir):
return True
return all(os.name != 'nt' and (n.startswith('.') or n == 'lost+found') for n in os.listdir(data_dir))
def keepalive_intvl(timeout, idle, cnt=3):
return max(1, int(float(timeout - idle) / cnt))
def keepalive_socket_options(timeout, idle, cnt=3):
yield (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
if sys.platform.startswith('linux'):
yield (socket.SOL_TCP, 18, int(timeout * 1000)) # TCP_USER_TIMEOUT
TCP_KEEPIDLE = getattr(socket, 'TCP_KEEPIDLE', None)
TCP_KEEPINTVL = getattr(socket, 'TCP_KEEPINTVL', None)
TCP_KEEPCNT = getattr(socket, 'TCP_KEEPCNT', None)
elif sys.platform.startswith('darwin'):
TCP_KEEPIDLE = 0x10 # (named "TCP_KEEPALIVE" in C)
TCP_KEEPINTVL = 0x101
TCP_KEEPCNT = 0x102
else:
return
intvl = keepalive_intvl(timeout, idle, cnt)
yield (socket.IPPROTO_TCP, TCP_KEEPIDLE, idle)
yield (socket.IPPROTO_TCP, TCP_KEEPINTVL, intvl)
yield (socket.IPPROTO_TCP, TCP_KEEPCNT, cnt)
def enable_keepalive(sock, timeout, idle, cnt=3):
SIO_KEEPALIVE_VALS = getattr(socket, 'SIO_KEEPALIVE_VALS', None)
if SIO_KEEPALIVE_VALS is not None: # Windows
intvl = keepalive_intvl(timeout, idle, cnt)
return sock.ioctl(SIO_KEEPALIVE_VALS, (1, idle * 1000, intvl * 1000))
for opt in keepalive_socket_options(timeout, idle, cnt):
sock.setsockopt(*opt)
+1 -1
View File
@@ -1 +1 @@
__version__ = '2.0.0'
__version__ = '1.6.5'
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env python
from patroni.raft_controller import main
if __name__ == '__main__':
main()
-9
View File
@@ -30,13 +30,6 @@ etcd:
#It is possible to change this behavior through by setting:
#use_proxies: true
#raft:
# data_dir: .
# self_addr: 127.0.0.1:2222
# partner_addrs:
# - 127.0.0.1:2223
# - 127.0.0.1:2224
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`
@@ -115,8 +108,6 @@ postgresql:
# same as KRB5CCNAME used by the GSS
# krb_server_keyfile: /var/spool/keytabs/postgres
unix_socket_directories: '.'
# Additional fencing script executed after acquiring the leader lock but before promoting the replica
#pre_promote: /path/to/pre_promote.sh
#watchdog:
# mode: automatic # Allowed values: off, automatic, required
-10
View File
@@ -30,13 +30,6 @@ etcd:
#It is possible to change this behavior through by setting:
#use_proxies: true
#raft:
# data_dir: .
# self_addr: 127.0.0.1:2223
# partner_addrs:
# - 127.0.0.1:2222
# - 127.0.0.1:2224
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`
@@ -112,9 +105,6 @@ postgresql:
basebackup:
- verbose
- max-rate: 100M
# Additional fencing script executed after acquiring the leader lock but before promoting the replica
#pre_promote: /path/to/pre_promote.sh
tags:
nofailover: false
noloadbalance: false
-7
View File
@@ -30,13 +30,6 @@ etcd:
#It is possible to change this behavior through by setting:
#use_proxies: true
#raft:
# data_dir: .
# self_addr: 127.0.0.1:2224
# partner_addrs:
# - 127.0.0.1:2222
# - 127.0.0.1:2223
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`
+3 -3
View File
@@ -1,4 +1,4 @@
urllib3[secure]>=1.19.1,!=1.21
urllib3>=1.19.1,!=1.21
boto
PyYAML
six >= 1.7
@@ -6,8 +6,8 @@ kazoo>=1.3.1
python-etcd>=0.4.3,<0.5
python-consul>=0.7.1
click>=4.1
prettytable>=0.7
texttable
python-dateutil
pysyncobj>=0.3.5
psutil>=2.0.0
cdiff
kubernetes>=2.0.0,<=10.0.1,!=4.0.*,!=5.0.*
+3 -4
View File
@@ -22,8 +22,8 @@ AUTHOR_EMAIL = '[email protected], [email protected], alexk
KEYWORDS = 'etcd governor patroni postgresql postgres ha haproxy confd' +\
' zookeeper exhibitor consul streaming replication kubernetes k8s'
EXTRAS_REQUIRE = {'aws': ['boto'], 'etcd': ['python-etcd'], 'etcd3': ['python-etcd'], 'consul': ['python-consul'],
'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'], 'kubernetes': [], 'raft': ['pysyncobj']}
EXTRAS_REQUIRE = {'aws': ['boto'], 'etcd': ['python-etcd'], 'consul': ['python-consul'],
'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'], 'kubernetes': ['kubernetes']}
COVERAGE_XML = True
COVERAGE_HTML = False
@@ -51,7 +51,6 @@ CLASSIFIERS = [
CONSOLE_SCRIPTS = ['patroni = patroni:main',
'patronictl = patroni.ctl:ctl',
'patroni_raft_controller = patroni.raft_controller:main',
"patroni_wale_restore = patroni.scripts.wale_restore:main",
"patroni_aws = patroni.scripts.aws:main"]
@@ -166,7 +165,7 @@ def setup_package(version):
continue
extra = False
for e, v in EXTRAS_REQUIRE.items():
if v and r.startswith(v[0]):
if r.startswith(v[0]):
EXTRAS_REQUIRE[e] = [r]
extra = True
if not extra:
+3 -11
View File
@@ -22,7 +22,6 @@ class MockResponse(object):
def __init__(self, status_code=200):
self.status_code = status_code
self.content = '{}'
self.reason = 'Not Found'
@property
def data(self):
@@ -36,10 +35,6 @@ class MockResponse(object):
def getheader(*args):
return ''
@staticmethod
def getheaders():
return {'content-type': 'json'}
def requests_get(url, **kwargs):
members = '[{"id":14855829450254237642,"peerURLs":["http://localhost:2380","http://localhost:7001"],' +\
@@ -98,7 +93,7 @@ class MockCursor(object):
elif sql.startswith('SELECT pg_catalog.to_char'):
replication_info = '[{"application_name":"walreceiver","client_addr":"1.2.3.4",' +\
'"state":"streaming","sync_state":"async","sync_priority":0}]'
self.results = [('', 0, '', 0, '', '', False, replication_info)]
self.results = [('', 0, '', '', '', '', False, replication_info)]
elif sql.startswith('SELECT name, setting'):
self.results = [('wal_segment_size', '2048', '8kB', 'integer', 'internal'),
('wal_block_size', '8192', None, 'integer', 'internal'),
@@ -165,13 +160,10 @@ class PostgresInit(unittest.TestCase):
'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', 'unix_socket_directories': '/tmp',
'trigger_file': 'bla', 'stats_temp_directory': '/tmp', 'zero_damaged_pages': '',
'force_parallel_mode': '1', 'constraint_exclusion': '',
'max_stack_depth': 'Z', 'vacuum_cost_limit': -1, 'vacuum_cost_delay': 200}
'track_commit_timestamp': 'off', 'unix_socket_directories': '/tmp', 'trigger_file': 'bla',
'stats_temp_directory': '/tmp'}
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.postgresql.CallbackExecutor', Mock())
@patch.object(ConfigHandler, 'write_postgresql_conf', Mock())
@patch.object(ConfigHandler, 'replace_pg_hba', Mock())
@patch.object(ConfigHandler, 'replace_pg_ident', Mock())
+10 -29
View File
@@ -148,8 +148,7 @@ class MockRestApiServer(RestApiServer):
self.serve_forever = Mock()
MockRestApiServer._BaseServer__is_shut_down = Mock()
MockRestApiServer._BaseServer__shutdown_request = True
config = config or {'listen': '127.0.0.1:8008', 'auth': 'test:test', 'certfile': 'dumb', 'verify_client': 'a',
'http_extra_headers': {'foo': 'bar'}, 'https_extra_headers': {'foo': 'sbar'}}
config = config or {'listen': '127.0.0.1:8008', 'auth': 'test:test', 'certfile': 'dumb', 'verify_client': 'a'}
super(MockRestApiServer, self).__init__(MockPatroni(), config)
Handler(MockRequest(request), ('0.0.0.0', 8080), self)
@@ -162,11 +161,7 @@ class TestRestApiHandler(unittest.TestCase):
_authorization = '\nAuthorization: Basic dGVzdDp0ZXN0'
def test_do_GET(self):
MockPatroni.dcs.cluster.last_leader_operation = 20
MockRestApiServer(RestApiHandler, 'GET /replica')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=10MB')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=10485760')
MockRestApiServer(RestApiHandler, 'GET /read-only')
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={})):
MockRestApiServer(RestApiHandler, 'GET /replica')
@@ -175,17 +170,17 @@ class TestRestApiHandler(unittest.TestCase):
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'state': 'running'})):
MockRestApiServer(RestApiHandler, 'GET /health')
MockRestApiServer(RestApiHandler, 'GET /master')
MockPatroni.dcs.cluster.sync.members = [MockPostgresql.name]
MockPatroni.dcs.cluster.sync.sync_standby = MockPostgresql.name
MockPatroni.dcs.cluster.is_synchronous_mode = Mock(return_value=True)
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})):
MockRestApiServer(RestApiHandler, 'GET /synchronous')
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})):
MockPatroni.dcs.cluster.sync.members = []
MockPatroni.dcs.cluster.sync.sync_standby = ''
MockRestApiServer(RestApiHandler, 'GET /asynchronous')
with patch.object(MockHa, 'is_leader', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /replica')
with patch.object(MockHa, 'is_standby_cluster', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
MockPatroni.ha.is_leader = Mock(return_value=True)
MockRestApiServer(RestApiHandler, 'GET /replica')
with patch.object(MockHa, 'is_standby_cluster', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
MockPatroni.dcs.cluster = None
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
MockRestApiServer(RestApiHandler, 'GET /master')
@@ -200,16 +195,6 @@ class TestRestApiHandler(unittest.TestCase):
def test_do_OPTIONS(self):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'OPTIONS / HTTP/1.0'))
def test_do_GET_liveness(self):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /liveness HTTP/1.0'))
def test_do_GET_readiness(self):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /readiness HTTP/1.0'))
with patch.object(MockHa, 'is_leader', Mock(return_value=True)):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /readiness HTTP/1.0'))
with patch.object(MockPostgresql, 'state', PropertyMock(return_value='stopped')):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /readiness HTTP/1.0'))
@patch.object(MockPostgresql, 'state', PropertyMock(return_value='stopped'))
def test_do_GET_patroni(self):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
@@ -251,8 +236,6 @@ class TestRestApiHandler(unittest.TestCase):
MockRestApiServer(RestApiHandler, request)
mock_dcs.set_config_value.return_value = False
MockRestApiServer(RestApiHandler, request)
mock_dcs.get_cluster.return_value.config = None
MockRestApiServer(RestApiHandler, request)
@patch.object(MockPatroni, 'dcs')
def test_do_PUT_config(self, mock_dcs):
@@ -482,9 +465,7 @@ class TestRestApiServer(unittest.TestCase):
mock_socket.context.wrap_socket.side_effect = socket.error
self.srv.process_request_thread((mock_socket, 1), '2')
@patch.object(socket.socket, 'accept')
def test_get_request(self, mock_accept):
newsock = Mock()
mock_accept.return_value = (newsock, '2')
@patch.object(socket.socket, 'accept', Mock(return_value=(1, '2')))
def test_get_request(self):
self.srv.socket = Mock()
self.assertEqual(self.srv.get_request(), ((self.srv.socket, newsock), '2'))
self.assertEqual(self.srv.get_request(), ((self.srv.socket, 1), '2'))
+1 -46
View File
@@ -1,10 +1,9 @@
import os
import sys
import unittest
import io
from mock import MagicMock, Mock, patch
from patroni.config import Config, ConfigParseError
from patroni.config import Config
from six.moves import builtins
@@ -56,7 +55,6 @@ class TestConfig(unittest.TestCase):
'PATRONI_ZOOKEEPER_HOSTS': "'host1:2181','host2:2181'",
'PATRONI_EXHIBITOR_HOSTS': 'host1,host2',
'PATRONI_EXHIBITOR_PORT': '8181',
'PATRONI_RAFT_PARTNER_ADDRS': "'host1:1234','host2:1234'",
'PATRONI_foo_HOSTS': '[host1,host2', # Exception in parse_list
'PATRONI_SUPERUSER_USERNAME': 'postgres',
'PATRONI_SUPERUSER_PASSWORD': 'zalando',
@@ -96,46 +94,3 @@ class TestConfig(unittest.TestCase):
self.config.set_dynamic_configuration(dynamic_configuration)
for name, value in dynamic_configuration['standby_cluster'].items():
self.assertEqual(self.config['standby_cluster'][name], value)
@patch('os.path.exists', Mock(return_value=True))
@patch('os.path.isfile', Mock(side_effect=lambda fname: fname != 'postgres0'))
@patch('os.path.isdir', Mock(return_value=True))
@patch('os.listdir', Mock(return_value=['01-specific.yml', '00-base.yml']))
def test_configuration_directory(self):
def open_mock(fname, *args, **kwargs):
if fname.endswith('00-base.yml'):
return io.StringIO(
u'''
test: True
test2:
child-1: somestring
child-2: 5
child-3: False
test3: True
test4:
- abc: 3
- abc: 4
''')
elif fname.endswith('01-specific.yml'):
return io.StringIO(
u'''
test: False
test2:
child-2: 10
child-3: !!null
test4:
- ab: 5
new-attr: True
''')
with patch.object(builtins, 'open', MagicMock(side_effect=open_mock)):
config = Config('postgres0')
self.assertEqual(config._local_configuration,
{'test': False, 'test2': {'child-1': 'somestring', 'child-2': 10},
'test3': True, 'test4': [{'ab': 5}], 'new-attr': True})
@patch('os.path.exists', Mock(return_value=True))
@patch('os.path.isfile', Mock(return_value=False))
@patch('os.path.isdir', Mock(return_value=False))
def test_invalid_path(self):
self.assertRaises(ConfigParseError, Config, 'postgres0')
+12 -6
View File
@@ -5,10 +5,10 @@ import unittest
from click.testing import CliRunner
from datetime import datetime, timedelta
from mock import patch, Mock
from patroni.ctl import ctl, store_config, load_config, output_members, get_dcs, parse_dcs, \
get_all_members, get_any_member, get_cursor, query_member, configure, PatroniCtlException, apply_config_changes, \
format_config_for_editing, show_diff, invoke_editor, format_pg_version, find_executable, CONFIG_FILE_PATH
from patroni.dcs.etcd import AbstractEtcdClientWithFailover, Failover
from patroni.ctl import ctl, store_config, load_config, output_members, get_dcs, parse_dcs, get_all_members, \
get_any_member, get_cursor, query_member, configure, PatroniCtlException, apply_config_changes, show_diff, \
format_config_for_editing, invoke_editor, format_pg_version, find_executable, print_output, CONFIG_FILE_PATH
from patroni.dcs.etcd import Client, Failover
from patroni.utils import tzutc
from psycopg2 import OperationalError
from urllib3 import PoolManager
@@ -37,7 +37,7 @@ class TestCtl(unittest.TestCase):
@patch('socket.getaddrinfo', socket_getaddrinfo)
def setUp(self):
with patch.object(AbstractEtcdClientWithFailover, 'machines') as mock_machines:
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')
@@ -165,6 +165,12 @@ class TestCtl(unittest.TestCase):
def test_get_dcs(self):
self.assertRaises(PatroniCtlException, get_dcs, {'dummy': {}}, 'dummy')
@patch('sys.platform', 'win32')
@patch('click.echo')
def test_print_output(self, mock_click_echo):
print_output(['a'], [])
mock_click_echo.assert_called_once_with('+---+\n| a |\n+---+')
@patch('psycopg2.connect', psycopg2_connect)
@patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None)))
@patch('patroni.ctl.get_dcs')
@@ -436,7 +442,7 @@ class TestCtl(unittest.TestCase):
cluster.members.append(cascade_member_wrong_tags)
mock_get_dcs.return_value.get_cluster = Mock(return_value=cluster)
result = self.runner.invoke(ctl, ['topology', 'dummy'])
assert '+\n| leader | 127.0.0.1:5435 | Leader |' in result.output
assert '+\n| leader | 127.0.0.1:5435 | Leader |' in result.output
assert '|\n| + other | 127.0.0.1:5436 | Replica |' in result.output
assert '|\n| + cascade | 127.0.0.1:5437 | Replica |' in result.output
assert '|\n| + wrong_cascade | 127.0.0.1:5438 | Replica |' in result.output
+20 -24
View File
@@ -5,7 +5,7 @@ import unittest
from dns.exception import DNSException
from mock import Mock, patch
from patroni.dcs.etcd import AbstractDCS, EtcdClient, Cluster, Etcd, EtcdError, DnsCachingResolver
from patroni.dcs.etcd import AbstractDCS, Client, Cluster, Etcd, EtcdError, DnsCachingResolver
from patroni.exceptions import DCSError
from patroni.utils import Retry
from urllib3.exceptions import ReadTimeoutError
@@ -17,7 +17,7 @@ def etcd_watch(self, key, index=None, timeout=None, recursive=None):
if timeout == 2.0:
raise etcd.EtcdWatchTimedOut
elif timeout == 5.0:
return etcd.EtcdResult('compareAndSwap', {})
return etcd.EtcdResult('delete', {})
elif 5 < timeout <= 10.0:
raise etcd.EtcdException
elif timeout == 20.0:
@@ -123,9 +123,9 @@ class TestClient(unittest.TestCase):
@patch('socket.getaddrinfo', socket_getaddrinfo)
@patch('patroni.dcs.etcd.requests_get', requests_get)
def setUp(self):
with patch.object(EtcdClient, '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 = EtcdClient({'srv': 'test', 'retry_timeout': 3}, DnsCachingResolver())
self.client = Client({'srv': 'test', 'retry_timeout': 3}, DnsCachingResolver())
self.client.http.request = http_request
self.client.http.request_encode_body = http_request
@@ -143,9 +143,10 @@ class TestClient(unittest.TestCase):
except Exception:
self.assertIsNone(machines)
@patch.object(EtcdClient, 'machines')
@patch.object(Client, 'machines')
def test_api_execute(self, mock_machines):
mock_machines.__get__ = Mock(return_value=['http://localhost:4001', 'http://localhost:2379'])
self.assertRaises(ValueError, self.client.api_execute, '', '')
self.client._base_uri = 'http://localhost:4001'
self.assertRaises(etcd.EtcdException, self.client.api_execute, '/', 'POST', timeout=0)
self.client._base_uri = 'http://localhost:4001'
@@ -156,17 +157,18 @@ class TestClient(unittest.TestCase):
self.client._machines_cache = [self.client._base_uri]
self.assertRaises(etcd.EtcdWatchTimedOut, self.client.api_execute, '/timeout', 'POST', params={'wait': 'true'})
self.assertRaises(etcd.EtcdWatchTimedOut, self.client.api_execute, '/timeout', 'POST', params={'wait': 'true'})
self.assertRaises(etcd.EtcdException, self.client.api_execute, '/', '')
with patch.object(EtcdClient, '_calculate_timeouts', Mock(side_effect=[(1, 1, 0), (1, 1, 0), (0, 1, 0)])),\
patch.object(EtcdClient, '_load_machines_cache', Mock(side_effect=Exception)):
with patch.object(Client, '_calculate_timeouts', Mock(side_effect=[(1, 1, 0), (1, 1, 0), (0, 1, 0)])),\
patch.object(Client, '_load_machines_cache', Mock(side_effect=Exception)):
self.client.http.request = Mock(side_effect=socket.error)
self.assertRaises(etcd.EtcdException, rtry, self.client.api_execute, '/', 'GET', params={'retry': rtry})
with patch.object(EtcdClient, '_calculate_timeouts', Mock(side_effect=[(1, 1, 0), (1, 1, 0), (0, 1, 0)])),\
patch.object(EtcdClient, '_load_machines_cache', Mock(return_value=True)):
with patch.object(Client, '_calculate_timeouts', Mock(side_effect=[(1, 1, 0), (1, 1, 0), (0, 1, 0)])),\
patch.object(Client, '_load_machines_cache', Mock(return_value=True)):
self.assertRaises(etcd.EtcdException, rtry, self.client.api_execute, '/', 'GET', params={'retry': rtry})
with patch.object(EtcdClient, '_do_http_request', Mock(side_effect=etcd.EtcdException)):
with patch.object(Client, '_do_http_request', Mock(side_effect=etcd.EtcdException)):
self.client._read_timeout = 0.01
self.assertRaises(etcd.EtcdException, self.client.api_execute, '/', 'GET')
@@ -182,7 +184,7 @@ class TestClient(unittest.TestCase):
def test__get_machines_cache_from_dns(self):
self.client._get_machines_cache_from_dns('error', 2379)
@patch.object(EtcdClient, 'machines')
@patch.object(Client, 'machines')
def test__refresh_machines_cache(self, mock_machines):
mock_machines.__get__ = Mock(side_effect=etcd.EtcdConnectionFailed)
self.assertIsNone(self.client._refresh_machines_cache())
@@ -203,10 +205,6 @@ class TestClient(unittest.TestCase):
timeout=1, source_address=('localhost', 53333),
socket_options=[(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)])
def test___del__(self):
self.client.http.clear = Mock(side_effect=TypeError)
del self.client
@patch('patroni.dcs.etcd.requests_get', requests_get)
@patch('socket.getaddrinfo', socket_getaddrinfo)
@@ -217,7 +215,7 @@ class TestEtcd(unittest.TestCase):
@patch('socket.getaddrinfo', socket_getaddrinfo)
def setUp(self):
with patch.object(EtcdClient, '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.etcd = Etcd({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 10,
'host': 'localhost:2379', 'scope': 'test', 'name': 'foo'})
@@ -228,18 +226,17 @@ class TestEtcd(unittest.TestCase):
@patch('dns.resolver.query', dns_query)
def test_get_etcd_client(self):
with patch('time.sleep', Mock(side_effect=SleepException)),\
patch.object(EtcdClient, 'machines') as mock_machines:
patch.object(Client, 'machines') as mock_machines:
mock_machines.__get__ = Mock(side_effect=etcd.EtcdException)
self.assertRaises(SleepException, self.etcd.get_etcd_client,
{'discovery_srv': 'test', 'retry_timeout': 10, 'cacert': '1', 'key': '1', 'cert': 1},
EtcdClient)
{'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}, EtcdClient)
{'url': 'https://test:2379', 'retry_timeout': 10})
self.assertRaises(SleepException, self.etcd.get_etcd_client,
{'hosts': 'foo:4001,bar', 'retry_timeout': 10}, EtcdClient)
{'hosts': 'foo:4001,bar', 'retry_timeout': 10})
mock_machines.__get__ = Mock(return_value=[])
self.assertRaises(SleepException, self.etcd.get_etcd_client,
{'proxy': 'https://user:password@test:2379', 'retry_timeout': 10}, EtcdClient)
{'proxy': 'https://user:password@test:2379', 'retry_timeout': 10})
def test_get_cluster(self):
cluster = self.etcd.get_cluster()
@@ -288,8 +285,7 @@ class TestEtcd(unittest.TestCase):
self.etcd.watch(None, 0)
self.etcd.get_cluster()
self.etcd.watch(20729, 1.5)
with patch('time.sleep', Mock()):
self.etcd.watch(20729, 4.5)
self.etcd.watch(20729, 4.5)
with patch.object(AbstractDCS, 'watch', Mock()):
self.assertTrue(self.etcd.watch(20729, 19.5))
self.assertRaises(SleepException, self.etcd.watch, 20729, 9.5)
-246
View File
@@ -1,246 +0,0 @@
import etcd
import json
import unittest
import urllib3
from mock import Mock, patch
from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3, Etcd3Error, Etcd3ClientError, RetryFailedError,\
InvalidAuthToken, Unavailable, Unknown, UnsupportedEtcdVersion, UserEmpty, base64_encode
from threading import Thread
from . import SleepException, MockResponse
def mock_urlopen(self, method, url, **kwargs):
ret = MockResponse()
if method == 'GET' and url.endswith('/version'):
ret.content = '{"etcdserver": "3.3.13", "etcdcluster": "3.3.0"}'
elif method != 'POST':
raise Exception('Unexpected request method: {0} {1} {2}'.format(method, url, kwargs))
elif url.endswith('/cluster/member/list'):
ret.content = '{"members":[{"clientURLs":["http://localhost:2379", "http://localhost:4001"]}]}'
elif url.endswith('/auth/authenticate'):
ret.content = '{"token":"authtoken"}'
elif url.endswith('/lease/grant'):
ret.content = '{"ID": "123"}'
elif url.endswith('/lease/keepalive'):
ret.content = '{"result":{"TTL":30}}'
elif url.endswith('/kv/range'):
ret.content = json.dumps({
"header": {"revision": "1"},
"kvs": [
{"key": base64_encode('/patroni/test/leader'),
"value": base64_encode('foo'), "lease": "bla", "mod_revision": '1'},
{"key": base64_encode('/patroni/test/members/foo'),
"value": base64_encode('{}'), "lease": "123", "mod_revision": '1'},
{"key": base64_encode('/patroni/test/failover'), "value": base64_encode('{}'), "mod_revision": '1'}
]
})
elif url.endswith('/watch'):
key = base64_encode('/patroni/test/config')
ret.read_chunked = Mock(return_value=[json.dumps({
'result': {'events': [
{'kv': {'key': key, 'value': base64_encode('bar'), 'mod_revision': '2'}},
{'kv': {'key': key, 'value': base64_encode('buzz'), 'mod_revision': '3'}},
{'type': 'DELETE', 'kv': {'key': key, 'mod_revision': '4'}},
{'kv': {'key': base64_encode('/patroni/test/optime/leader'),
'value': base64_encode('1234567'), 'mod_revision': '5'}},
]}
})[:-1].encode('utf-8'), b'}{"error":{"grpc_code":14,"message":"","http_code":503}}'])
elif url.endswith('/kv/put') or url.endswith('/kv/txn'):
ret.status_code = 400
ret.content = '{"code":5,"error":"etcdserver: requested lease not found"}'
elif not url.endswith('/kv/deleterange'):
raise Exception('Unexpected url: {0} {1} {2}'.format(method, url, kwargs))
return ret
class BaseTestEtcd3(unittest.TestCase):
@patch.object(Thread, 'start', Mock())
@patch.object(urllib3.PoolManager, 'urlopen', mock_urlopen)
def setUp(self):
self.etcd3 = Etcd3({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 10,
'host': 'localhost:2378', 'scope': 'test', 'name': 'foo',
'username': 'etcduser', 'password': 'etcdpassword'})
self.client = self.etcd3._client
self.kv_cache = self.client._kv_cache
class TestKVCache(BaseTestEtcd3):
def test__do_watch(self):
self.client.watchprefix = Mock(return_value=False)
self.assertRaises(AttributeError, self.kv_cache._do_watch, '1')
@patch('time.sleep', Mock(side_effect=SleepException))
@patch('patroni.dcs.etcd3.KVCache._build_cache', Mock(side_effect=Exception))
def test_run(self):
self.assertRaises(SleepException, self.kv_cache.run)
@patch.object(urllib3.PoolManager, 'urlopen', mock_urlopen)
def test_kill_stream(self):
self.assertRaises(Unavailable, self.kv_cache._do_watch, '1')
self.kv_cache.kill_stream()
with patch.object(MockResponse, 'connection', create=True) as mock_conn:
self.kv_cache.kill_stream()
mock_conn.sock.close.side_effect = Exception
self.kv_cache.kill_stream()
class TestPatroniEtcd3Client(BaseTestEtcd3):
@patch('patroni.dcs.etcd3.Etcd3Client.authenticate', Mock(side_effect=Exception))
def test__init__(self):
self.assertRaises(SystemExit, self.setUp)
@patch.object(urllib3.PoolManager, 'urlopen')
def test_call_rpc(self, mock_urlopen):
request = {'key': base64_encode('/patroni/test/leader')}
mock_urlopen.return_value = MockResponse()
mock_urlopen.return_value.content = '{"succeeded":true,"header":{"revision":"1"}}'
self.client.call_rpc('/kv/txn', {'success': [{'request_delete_range': request}]})
self.client.call_rpc('/kv/put', request)
self.client.call_rpc('/kv/deleterange', request)
@patch('time.time', Mock(side_effect=[1, 10.9, 100]))
def test__wait_cache(self):
with self.kv_cache.condition:
self.assertRaises(RetryFailedError, self.client._wait_cache, 10)
@patch.object(urllib3.PoolManager, 'urlopen')
def test__restart_watcher(self, mock_urlopen):
mock_urlopen.return_value = MockResponse()
mock_urlopen.return_value.status_code = 400
mock_urlopen.return_value.content = '{"code":9,"error":"etcdserver: authentication is not enabled"}'
self.client.authenticate()
@patch.object(urllib3.PoolManager, 'urlopen')
def test__handle_auth_errors(self, mock_urlopen):
mock_urlopen.return_value = MockResponse()
mock_urlopen.return_value.content = '{"code":3,"error":"etcdserver: user name is empty"}'
mock_urlopen.return_value.status_code = 403
self.client._cluster_version = (3, 1, 5)
self.assertRaises(UnsupportedEtcdVersion, self.client.deleteprefix, 'foo')
self.client._cluster_version = (3, 3, 13)
self.assertRaises(UserEmpty, self.client.deleteprefix, 'foo')
mock_urlopen.return_value.content = '{"code":16,"error":"etcdserver: invalid auth token"}'
self.assertRaises(InvalidAuthToken, self.client.deleteprefix, 'foo')
with patch.object(PatroniEtcd3Client, 'authenticate', Mock(return_value=True)):
self.assertRaises(InvalidAuthToken, self.client.deleteprefix, 'foo')
self.client.username = None
self.assertRaises(InvalidAuthToken, self.client.deleteprefix, 'foo')
def test__handle_server_response(self):
response = MockResponse()
response.content = '{"code":0,"error":"'
self.assertRaises(etcd.EtcdException, self.client._handle_server_response, response)
response.status_code = 400
self.assertRaises(Unknown, self.client._handle_server_response, response)
response.content = '{"error":{"grpc_code":0,"message":"","http_code":400}}'
try:
self.client._handle_server_response(response)
except Unknown as e:
self.assertEqual(e.as_dict(), {'code': 2, 'codeText': 'OK', 'error': u'', 'status': 400})
@patch.object(urllib3.PoolManager, 'urlopen')
def test__ensure_version_prefix(self, mock_urlopen):
self.client.version_prefix = None
mock_urlopen.return_value = MockResponse()
mock_urlopen.return_value.content = '{"etcdserver": "3.0.3", "etcdcluster": "3.0.0"}'
self.assertRaises(UnsupportedEtcdVersion, self.client._ensure_version_prefix, '')
mock_urlopen.return_value.content = '{"etcdserver": "3.0.4", "etcdcluster": "3.0.0"}'
self.client._ensure_version_prefix('')
self.assertEqual(self.client.version_prefix, '/v3alpha')
mock_urlopen.return_value.content = '{"etcdserver": "3.4.4", "etcdcluster": "3.4.0"}'
self.client._ensure_version_prefix('')
self.assertEqual(self.client.version_prefix, '/v3')
@patch.object(urllib3.PoolManager, 'urlopen', mock_urlopen)
class TestEtcd3(BaseTestEtcd3):
@patch.object(Thread, 'start', Mock())
@patch.object(urllib3.PoolManager, 'urlopen', mock_urlopen)
def setUp(self):
super(TestEtcd3, self).setUp()
self.assertRaises(AttributeError, self.kv_cache._build_cache)
self.kv_cache._is_ready = True
self.etcd3.get_cluster()
def test_get_cluster(self):
self.assertIsInstance(self.etcd3.get_cluster(), Cluster)
self.client._kv_cache = None
with patch.object(urllib3.PoolManager, 'urlopen') as mock_urlopen:
mock_urlopen.side_effect = UnsupportedEtcdVersion('')
self.assertRaises(UnsupportedEtcdVersion, self.etcd3.get_cluster)
mock_urlopen.side_effect = SleepException()
self.assertRaises(Etcd3Error, self.etcd3.get_cluster)
def test_touch_member(self):
self.etcd3.touch_member({})
self.etcd3._lease = 'bla'
self.etcd3.touch_member({})
with patch.object(PatroniEtcd3Client, 'lease_grant', Mock(side_effect=Etcd3ClientError)):
self.etcd3.touch_member({})
def test__update_leader(self):
self.etcd3._lease = None
self.etcd3.update_leader('123')
self.etcd3.update_leader('124')
def test_attempt_to_acquire_leader(self):
self.etcd3._lease = None
self.assertFalse(self.etcd3.attempt_to_acquire_leader())
def test_set_ttl(self):
self.etcd3.set_ttl(20)
@patch.object(PatroniEtcd3Client, 'lease_keepalive', Mock(return_value=False))
def test_refresh_lease(self):
self.etcd3._last_lease_refresh = 0
self.etcd3.refresh_lease()
@patch('time.sleep', Mock(side_effect=SleepException))
@patch.object(PatroniEtcd3Client, 'lease_keepalive', Mock(return_value=False))
@patch.object(PatroniEtcd3Client, 'lease_grant', Mock(side_effect=Etcd3ClientError))
def test_create_lease(self):
self.etcd3._lease = None
self.etcd3._last_lease_refresh = 0
self.assertRaises(SleepException, self.etcd3.create_lease)
def test_set_failover_value(self):
self.etcd3.set_failover_value('', 1)
def test_set_config_value(self):
self.etcd3.set_config_value('')
def test_initialize(self):
self.etcd3.initialize()
def test_cancel_initialization(self):
self.etcd3.cancel_initialization()
def test_delete_leader(self):
self.etcd3.delete_leader()
def test_delete_cluster(self):
self.etcd3.delete_cluster()
def test_set_history_value(self):
self.etcd3.set_history_value('')
def test_set_sync_state_value(self):
self.etcd3.set_sync_state_value('')
def test_delete_sync_state(self):
self.etcd3.delete_sync_state()
def test_watch(self):
self.etcd3.set_ttl(10)
self.etcd3.watch(None, 0)
self.etcd3.watch(None, 0)
def test_set_socket_options(self):
with patch('socket.SIO_KEEPALIVE_VALS', 1, create=True):
self.etcd3.set_socket_options(Mock(), None)
+17 -40
View File
@@ -3,11 +3,11 @@ import etcd
import os
import sys
from mock import call, Mock, MagicMock, PropertyMock, patch, mock_open
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
from patroni.config import Config
from patroni.dcs import Cluster, ClusterConfig, Failover, Leader, Member, get_dcs, SyncState, TimelineHistory
from patroni.dcs.etcd import AbstractEtcdClientWithFailover
from patroni.exceptions import DCSError, PostgresConnectionException, PatroniFatalException
from patroni.dcs.etcd import Client
from patroni.exceptions import DCSError, PostgresConnectionException, PatroniException
from patroni.ha import Ha, _MemberStatus
from patroni.postgresql import Postgresql
from patroni.postgresql.bootstrap import Bootstrap
@@ -48,7 +48,7 @@ def get_cluster_not_initialized_without_leader(cluster_config=None):
def get_cluster_initialized_without_leader(leader=False, failover=None, sync=None, cluster_config=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})
leader = Leader(0, 0, m1 if leader else Member(0, '', 28, {}))
leader = 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',
@@ -185,7 +185,7 @@ class TestHa(PostgresInit):
@patch.object(etcd.Client, 'read', etcd_read)
def setUp(self):
super(TestHa, self).setUp()
with patch.object(AbstractEtcdClientWithFailover, 'machines') as mock_machines:
with patch.object(Client, 'machines') as mock_machines:
mock_machines.__get__ = Mock(return_value=['http://remotehost:2379'])
self.p.set_state('running')
self.p.set_role('replica')
@@ -302,19 +302,6 @@ class TestHa(PostgresInit):
self.p.is_leader = false
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
def test_promotion_cancelled_after_pre_promote_failed(self):
self.p.is_leader = false
self.p._pre_promote = false
self.ha._is_healthiest_node = true
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
self.assertEqual(self.ha.run_cycle(), 'Promotion cancelled because the pre-promote script failed')
self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
def test_lost_leader_lock_during_promote(self):
with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)):
self.ha._async_executor.schedule('promote')
self.assertEqual(self.ha.run_cycle(), 'lost leader before promote')
def test_long_promote(self):
self.ha.cluster.is_unlocked = false
self.ha.has_lock = true
@@ -435,7 +422,7 @@ class TestHa(PostgresInit):
self.e.initialize = true
self.ha.bootstrap()
self.p.is_running = false
self.assertRaises(PatroniFatalException, self.ha.post_bootstrap)
self.assertRaises(PatroniException, self.ha.post_bootstrap)
def test_bootstrap_release_initialize_key_on_watchdog_failure(self):
self.ha.cluster = get_cluster_not_initialized_without_leader()
@@ -445,7 +432,7 @@ class TestHa(PostgresInit):
self.p.is_leader = true
with patch.object(Watchdog, 'activate', Mock(return_value=False)):
self.assertEqual(self.ha.post_bootstrap(), 'running post_bootstrap')
self.assertRaises(PatroniFatalException, self.ha.post_bootstrap)
self.assertRaises(PatroniException, self.ha.post_bootstrap)
@patch('psycopg2.connect', psycopg2_connect)
def test_reinitialize(self):
@@ -869,7 +856,7 @@ class TestHa(PostgresInit):
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([])
mock_set_sync.assert_called_once_with(None)
mock_set_sync.reset_mock()
# Test sync key not touched when not there
@@ -877,14 +864,14 @@ class TestHa(PostgresInit):
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([])
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'], ['other']))
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()
@@ -892,18 +879,10 @@ class TestHa(PostgresInit):
mock_set_sync.reset_mock()
# Test sync standby is replaced when switching standbys
self.p.pick_synchronous_standby = Mock(return_value=(['other2'], []))
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'])
# Test sync standby is replaced when new standby is joined
self.p.pick_synchronous_standby = Mock(return_value=(['other2', 'other3'], ['other2']))
self.ha.dcs.write_sync_state = Mock(return_value=True)
self.ha.run_cycle()
# mock_set_sync.assert_called_once_with(['other2'])
calls = [call(['other2']), call(['other2', 'other3'])]
mock_set_sync.assert_has_calls(calls)
mock_set_sync.assert_called_once_with('other2')
mock_set_sync.reset_mock()
# Test sync standby is not disabled when updating dcs fails
@@ -916,7 +895,7 @@ class TestHa(PostgresInit):
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'], ['other2']))
self.p.pick_synchronous_standby = Mock(return_value=('other2', True))
self.ha.run_cycle()
self.ha.dcs.get_cluster.assert_called_once()
self.assertEqual(self.ha.dcs.write_sync_state.call_count, 2)
@@ -939,9 +918,9 @@ class TestHa(PostgresInit):
# Test sync set to '*' when synchronous_mode_strict is enabled
mock_set_sync.reset_mock()
self.ha.is_synchronous_mode_strict = true
self.p.pick_synchronous_standby = Mock(return_value=([], []))
self.p.pick_synchronous_standby = Mock(return_value=(None, False))
self.ha.run_cycle()
mock_set_sync.assert_called_once_with(['*'])
mock_set_sync.assert_called_once_with('*')
def test_sync_replication_become_master(self):
self.ha.is_synchronous_mode = true
@@ -956,7 +935,7 @@ class TestHa(PostgresInit):
# When we just became master nobody is sync
self.assertEqual(self.ha.enforce_master_role('msg', 'promote msg'), 'promote msg')
mock_set_sync.assert_called_once_with([])
mock_set_sync.assert_called_once_with(None)
mock_write_sync.assert_called_once_with('leader', None, index=0)
mock_set_sync.reset_mock()
@@ -1055,7 +1034,7 @@ class TestHa(PostgresInit):
def test_shutdown(self):
self.p.is_running = false
self.ha.is_leader = true
self.ha.has_lock = true
self.ha.shutdown()
@patch('time.sleep', Mock())
@@ -1109,5 +1088,3 @@ class TestHa(PostgresInit):
def test_run_cycle(self):
self.ha.dcs.touch_member = Mock(side_effect=DCSError('foo'))
self.assertEqual(self.ha.run_cycle(), 'Unexpected exception raised, please report it as a BUG')
self.ha.dcs.touch_member = Mock(side_effect=PatroniFatalException('foo'))
self.assertRaises(PatroniFatalException, self.ha.run_cycle)
+24 -134
View File
@@ -1,15 +1,11 @@
import json
import socket
import time
import unittest
from mock import Mock, mock_open, patch
from patroni.dcs.kubernetes import k8s_client, k8s_config, K8sConfig, K8sConnectionFailed,\
K8sException, K8sObject, Kubernetes, KubernetesError, KubernetesRetriableException,\
Retry, RetryFailedError, SERVICE_HOST_ENV_NAME, SERVICE_PORT_ENV_NAME
from six.moves import builtins
from mock import Mock, patch
from patroni.dcs.kubernetes import Kubernetes, KubernetesError, k8s_client, RetryFailedError
from threading import Thread
from . import MockResponse, SleepException
from . import SleepException
def mock_list_namespaced_config_map(*args, **kwargs):
@@ -55,139 +51,35 @@ def mock_namespaced_kind(*args, **kwargs):
return mock
def mock_load_k8s_config(self, *args, **kwargs):
self._server = ''
class TestK8sConfig(unittest.TestCase):
def test_load_incluster_config(self):
for env in ({}, {SERVICE_HOST_ENV_NAME: '', SERVICE_PORT_ENV_NAME: ''}):
with patch('os.environ', env):
self.assertRaises(k8s_config.ConfigException, k8s_config.load_incluster_config)
with patch('os.environ', {SERVICE_HOST_ENV_NAME: 'a', SERVICE_PORT_ENV_NAME: '1'}),\
patch('os.path.isfile', Mock(side_effect=[False, True, True, False, True, True, True, True])),\
patch.object(builtins, 'open', Mock(side_effect=[
mock_open()(), mock_open(read_data='a')(), mock_open(read_data='a')(),
mock_open()(), mock_open(read_data='a')(), mock_open(read_data='a')()])):
for _ in range(0, 4):
self.assertRaises(k8s_config.ConfigException, k8s_config.load_incluster_config)
k8s_config.load_incluster_config()
self.assertEqual(k8s_config.server, 'https://a:1')
def test_load_kube_config(self):
config = {
"current-context": "local",
"contexts": [{"name": "local", "context": {"user": "local", "cluster": "local"}}],
"clusters": [{"name": "local", "cluster": {"server": "https://a:1/", "certificate-authority": "a"}}],
"users": [{"name": "local", "user": {"username": "a", "password": "b", "client-certificate": "c"}}]
}
with patch.object(builtins, 'open', mock_open(read_data=json.dumps(config))):
k8s_config.load_kube_config()
self.assertEqual(k8s_config.server, 'https://a:1')
self.assertEqual(k8s_config.pool_config, {'ca_certs': 'a', 'cert_file': 'c', 'cert_reqs': 'CERT_REQUIRED',
'maxsize': 10, 'num_pools': 10})
config["users"][0]["user"]["token"] = "token"
with patch.object(builtins, 'open', mock_open(read_data=json.dumps(config))):
k8s_config.load_kube_config()
self.assertEqual(k8s_config.headers.get('authorization'), 'Bearer token')
@patch('urllib3.PoolManager.request')
class TestApiClient(unittest.TestCase):
@patch.object(K8sConfig, '_server', '', create=True)
@patch('urllib3.PoolManager.request', Mock())
def setUp(self):
self.a = k8s_client.ApiClient(True)
self.mock_get_ep = MockResponse()
self.mock_get_ep.content = '{"subsets":[{"ports":[{"name":"https","protocol":"TCP","port":443}],' +\
'"addresses":[{"ip":"127.0.0.1"},{"ip":"127.0.0.2"}]}]}'
def test__do_http_request(self, mock_request):
mock_request.side_effect = [self.mock_get_ep] + [socket.timeout]
self.assertRaises(K8sException, self.a.call_api, 'GET', 'f')
@patch('time.sleep', Mock())
def test_request(self, mock_request):
retry = Retry(deadline=10, max_delay=1, max_tries=1, retry_exceptions=KubernetesRetriableException)
mock_request.side_effect = [self.mock_get_ep] + 3 * [socket.timeout] + [k8s_client.rest.ApiException(500, '')]
self.assertRaises(k8s_client.rest.ApiException, retry, self.a.call_api, 'GET', 'f', _retry=retry)
mock_request.side_effect = [self.mock_get_ep, socket.timeout, Mock(), self.mock_get_ep]
self.assertRaises(k8s_client.rest.ApiException, retry, self.a.call_api, 'GET', 'f', _retry=retry)
retry.deadline = 0.0001
mock_request.side_effect = [socket.timeout, socket.timeout, self.mock_get_ep]
self.assertRaises(K8sConnectionFailed, retry, self.a.call_api, 'GET', 'f', _retry=retry)
def test__refresh_api_servers_cache(self, mock_request):
mock_request.side_effect = k8s_client.rest.ApiException(403, '')
self.a.refresh_api_servers_cache()
class TestCoreV1Api(unittest.TestCase):
@patch('urllib3.PoolManager.request', Mock())
@patch.object(K8sConfig, '_server', '', create=True)
def setUp(self):
self.a = k8s_client.CoreV1Api()
self.a._api_client.pool_manager.request = Mock(return_value=MockResponse())
def test_create_namespaced_service(self):
self.assertEqual(str(self.a.create_namespaced_service('default', {}, _request_timeout=2)), '{}')
def test_list_namespaced_endpoints(self):
self.a._api_client.pool_manager.request.return_value.content = '{"items": [1,2,3]}'
self.assertIsInstance(self.a.list_namespaced_endpoints('default'), K8sObject)
def test_patch_namespaced_config_map(self):
self.assertEqual(str(self.a.patch_namespaced_config_map('foo', 'default', {}, _request_timeout=(1, 2))), '{}')
def test_list_namespaced_pod(self):
self.a._api_client.pool_manager.request.return_value.status_code = 409
self.a._api_client.pool_manager.request.return_value.content = 'foo'
try:
self.a.list_namespaced_pod('default', label_selector='foo=bar')
self.assertFail()
except k8s_client.rest.ApiException as e:
self.assertTrue('Reason: ' in str(e))
def test_delete_namespaced_pod(self):
self.assertEqual(str(self.a.delete_namespaced_pod('foo', 'default', _request_timeout=(1, 2), body={})), '{}')
class BaseTestKubernetes(unittest.TestCase):
@patch('urllib3.PoolManager.request', Mock())
@patch('socket.TCP_KEEPIDLE', 4, create=True)
@patch('socket.TCP_KEEPINTVL', 5, create=True)
@patch('socket.TCP_KEEPCNT', 6, create=True)
@patch('kubernetes.config.load_kube_config', Mock())
@patch('kubernetes.client.api_client.ThreadPool', Mock(), create=True)
@patch.object(Thread, 'start', Mock())
@patch.object(K8sConfig, 'load_kube_config', mock_load_k8s_config)
@patch.object(K8sConfig, 'load_incluster_config', Mock(side_effect=k8s_config.ConfigException))
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_pod', mock_list_namespaced_pod, create=True)
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_config_map', mock_list_namespaced_config_map, create=True)
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_pod', mock_list_namespaced_pod)
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_config_map', mock_list_namespaced_config_map)
def setUp(self, config=None):
config = config or {}
config.update(ttl=30, scope='test', name='p-0', loop_wait=10,
retry_timeout=10, labels={'f': 'b'}, bypass_api_service=True)
config.update(ttl=30, scope='test', name='p-0', loop_wait=10, retry_timeout=10, labels={'f': 'b'})
self.k = Kubernetes(config)
self.assertRaises(AttributeError, self.k._pods._build_cache)
self.k._pods._is_ready = True
self.assertRaises(TypeError, self.k._kinds._build_cache)
self.assertRaises(AttributeError, self.k._kinds._build_cache)
self.k._kinds._is_ready = True
self.k.get_cluster()
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_config_map', mock_namespaced_kind, create=True)
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_config_map', mock_namespaced_kind)
class TestKubernetesConfigMaps(BaseTestKubernetes):
@patch('time.time', Mock(side_effect=[1, 10.9, 100]))
def test__wait_caches(self):
self.k._pods._is_ready = False
with self.k._condition:
self.assertRaises(RetryFailedError, self.k._wait_caches, time.time() + 10)
self.assertRaises(RetryFailedError, self.k._wait_caches)
@patch('time.time', Mock(return_value=time.time() + 100))
def test_get_cluster(self):
@@ -203,14 +95,13 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
self.k.take_leader()
def test_manual_failover(self):
with patch.object(k8s_client.CoreV1Api, 'patch_namespaced_config_map',
Mock(side_effect=RetryFailedError('')), create=True):
with patch.object(k8s_client.CoreV1Api, 'patch_namespaced_config_map', Mock(side_effect=RetryFailedError(''))):
self.k.manual_failover('foo', 'bar')
def test_set_config_value(self):
self.k.set_config_value('{}')
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_pod', create=True)
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_pod')
def test_touch_member(self, mock_patch_namespaced_pod):
mock_patch_namespaced_pod.return_value.metadata.resource_version = '10'
self.k.touch_member({'role': 'replica'})
@@ -228,7 +119,7 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
self.k.cancel_initialization()
@patch.object(k8s_client.CoreV1Api, 'delete_collection_namespaced_config_map',
Mock(side_effect=k8s_client.rest.ApiException(403, '')), create=True)
Mock(side_effect=k8s_client.rest.ApiException(403, '')))
def test_delete_cluster(self):
self.k.delete_cluster()
@@ -243,11 +134,11 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
class TestKubernetesEndpoints(BaseTestKubernetes):
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_endpoints', mock_list_namespaced_endpoints, create=True)
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_endpoints', mock_list_namespaced_endpoints)
def setUp(self, config=None):
super(TestKubernetesEndpoints, self).setUp({'use_endpoints': True, 'pod_ip': '10.0.0.0'})
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', create=True)
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints')
def test_update_leader(self, mock_patch_namespaced_endpoints):
self.assertIsNotNone(self.k.update_leader('123'))
args = mock_patch_namespaced_endpoints.call_args[0]
@@ -257,11 +148,11 @@ class TestKubernetesEndpoints(BaseTestKubernetes):
self.k._kinds._object_cache['test'].metadata.annotations['leader'] = 'p-1'
self.assertFalse(self.k.update_leader('123'))
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', mock_namespaced_kind, create=True)
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', mock_namespaced_kind)
def test_update_leader_with_restricted_access(self):
self.assertIsNotNone(self.k.update_leader('123', True))
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', create=True)
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints')
def test__update_leader_with_retry(self, mock_patch):
mock_patch.side_effect = k8s_client.rest.ApiException(502, '')
self.assertFalse(self.k.update_leader('123'))
@@ -277,15 +168,14 @@ class TestKubernetesEndpoints(BaseTestKubernetes):
self.assertIsNotNone(self.k._update_leader_with_retry({}, '1', []))
@patch.object(k8s_client.CoreV1Api, 'create_namespaced_endpoints',
Mock(side_effect=[k8s_client.rest.ApiException(500, ''),
k8s_client.rest.ApiException(502, '')]), create=True)
Mock(side_effect=[k8s_client.rest.ApiException(500, ''), k8s_client.rest.ApiException(502, '')]))
def test_delete_sync_state(self):
self.assertFalse(self.k.delete_sync_state())
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_pod', mock_namespaced_kind, create=True)
@patch.object(k8s_client.CoreV1Api, 'create_namespaced_endpoints', mock_namespaced_kind, create=True)
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_pod', mock_namespaced_kind)
@patch.object(k8s_client.CoreV1Api, 'create_namespaced_endpoints', mock_namespaced_kind)
@patch.object(k8s_client.CoreV1Api, 'create_namespaced_service',
Mock(side_effect=[True, False, k8s_client.rest.ApiException(500, '')]), create=True)
Mock(side_effect=[True, False, k8s_client.rest.ApiException(500, '')]))
def test__create_config_service(self):
self.assertIsNotNone(self.k.patch_or_create_config({'foo': 'bar'}))
self.assertIsNotNone(self.k.patch_or_create_config({'foo': 'bar'}))
@@ -294,7 +184,7 @@ class TestKubernetesEndpoints(BaseTestKubernetes):
class TestCacheBuilder(BaseTestKubernetes):
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_config_map', mock_list_namespaced_config_map, create=True)
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_config_map', mock_list_namespaced_config_map)
@patch('patroni.dcs.kubernetes.ObjectCache._watch')
def test__build_cache(self, mock_response):
mock_response.return_value.read_chunked.return_value = [json.dumps(
@@ -305,7 +195,7 @@ class TestCacheBuilder(BaseTestKubernetes):
'name': self.k.config_path, 'resourceVersion': '3'}}}
) + '\n' + json.dumps(
{'type': 'MDIFIED', 'object': {'metadata': {'name': self.k.config_path}}}
) + '\n').encode('utf-8'), b'{"object":{', b'"code":410}}\n']
) + '\n' + json.dumps({'object': {'code': 410}}) + '\n').encode('utf-8')]
self.k._kinds._build_cache()
@patch('patroni.dcs.kubernetes.logger.error', Mock(side_effect=SleepException))
+1 -2
View File
@@ -43,8 +43,7 @@ class TestPatroniLogger(unittest.TestCase):
_LOG.exception('test')
logger.start()
with patch.object(logging.Handler, 'format', Mock(side_effect=Exception)),\
patch('_pytest.logging.LogCaptureHandler.emit', Mock()):
with patch.object(logging.Handler, 'format', Mock(side_effect=Exception)):
logging.error('test')
self.assertEqual(logger.log_handler.maxBytes, config['log']['file_size'])
+4 -4
View File
@@ -9,7 +9,7 @@ import patroni.config as config
from mock import Mock, PropertyMock, patch
from patroni.api import RestApiServer
from patroni.async_executor import AsyncExecutor
from patroni.dcs.etcd import AbstractEtcdClientWithFailover
from patroni.dcs.etcd import Client
from patroni.exceptions import DCSError
from patroni.postgresql import Postgresql
from patroni.postgresql.config import ConfigHandler
@@ -53,7 +53,7 @@ class TestPatroni(unittest.TestCase):
@patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock())
@patch.object(etcd.Client, 'read', etcd_read)
@patch.object(Thread, 'start', Mock())
@patch.object(AbstractEtcdClientWithFailover, 'machines', PropertyMock(return_value=['http://remotehost:2379']))
@patch.object(Client, 'machines', PropertyMock(return_value=['http://remotehost:2379']))
def setUp(self):
self._handlers = logging.getLogger().handlers[:]
RestApiServer._BaseServer__is_shut_down = Mock()
@@ -75,7 +75,7 @@ class TestPatroni(unittest.TestCase):
@patch('sys.argv', ['patroni.py', 'postgres0.yml'])
@patch('time.sleep', Mock(side_effect=SleepException))
@patch.object(etcd.Client, 'delete', Mock())
@patch.object(AbstractEtcdClientWithFailover, 'machines', PropertyMock(return_value=['http://remotehost:2379']))
@patch.object(Client, 'machines', PropertyMock(return_value=['http://remotehost:2379']))
@patch.object(Thread, 'join', Mock())
def test_patroni_patroni_main(self):
with patch('subprocess.call', Mock(return_value=1)):
@@ -167,7 +167,7 @@ class TestPatroni(unittest.TestCase):
def test_reload_config(self):
self.p.reload_config()
self.p.get_tags = Mock(side_effect=Exception)
self.p.reload_config(local=True)
self.p.reload_config()
def test_nosync(self):
self.p.tags['nosync'] = True
+36 -61
View File
@@ -98,6 +98,7 @@ class TestPostgresql(BaseTestPostgresql):
def setUp(self):
super(TestPostgresql, self).setUp()
self.p.config.write_postgresql_conf()
self.p._callback_executor = Mock()
@patch('subprocess.Popen')
@patch.object(Postgresql, 'wait_for_startup')
@@ -272,10 +273,10 @@ class TestPostgresql(BaseTestPostgresql):
@patch.object(Postgresql, 'major_version', PropertyMock(return_value=100000))
@patch.object(Postgresql, 'primary_conninfo', Mock(return_value='host=1'))
def test__read_recovery_params_pre_v12(self):
self.p.config.write_recovery_conf({'standby_mode': 'off', 'primary_conninfo': {'password': 'foo'}})
self.p.config.write_recovery_conf({'standby_mode': 'on', 'primary_conninfo': {'password': 'foo'}})
self.assertEqual(self.p.config.check_recovery_conf(None), (True, True))
self.assertEqual(self.p.config.check_recovery_conf(None), (True, True))
self.p.config.write_recovery_conf({'restore_command': '\n'})
self.p.config.write_recovery_conf({'standby_mode': '\n'})
with patch('patroni.postgresql.config.mtime', mock_mtime):
self.assertEqual(self.p.config.check_recovery_conf(None), (True, True))
@@ -357,22 +358,10 @@ class TestPostgresql(BaseTestPostgresql):
mock_is_running.return_value = False
self.assertFalse(self.p.is_healthy())
@patch('psutil.Popen')
def test_promote(self, mock_popen):
mock_popen.return_value.wait.return_value = 0
task = CriticalTask()
self.assertTrue(self.p.promote(0, task))
def test_promote(self):
self.p.set_role('replica')
self.p.config._config['pre_promote'] = 'test'
with patch('patroni.postgresql.cancellable.CancellableSubprocess.is_cancelled', PropertyMock(return_value=1)):
self.assertFalse(self.p.promote(0, task))
mock_popen.side_effect = Exception
self.assertFalse(self.p.promote(0, task))
task.reset()
task.cancel()
self.assertFalse(self.p.promote(0, task))
self.assertIsNone(self.p.promote(0))
self.assertTrue(self.p.promote(0))
def test_timeline_wal_position(self):
self.assertEqual(self.p.timeline_wal_position(), (1, 2, 1))
@@ -413,10 +402,6 @@ class TestPostgresql(BaseTestPostgresql):
@patch('os.rename', Mock())
@patch('os.path.isdir', Mock(return_value=True))
@patch('os.unlink', Mock())
@patch('os.symlink', Mock())
@patch('patroni.postgresql.Postgresql.pg_wal_realpath', Mock(return_value={'pg_wal': '/mnt/pg_wal'}))
@patch('patroni.postgresql.Postgresql.pg_tblspc_realpaths', Mock(return_value={'42': '/mnt/tablespaces/archive'}))
def test_move_data_directory(self):
self.p.move_data_directory()
with patch('os.rename', Mock(side_effect=OSError)):
@@ -430,9 +415,11 @@ class TestPostgresql(BaseTestPostgresql):
def test_remove_data_directory(self):
def _symlink(src, dst):
if os.name != 'nt': # os.symlink under Windows needs admin rights skip it
try:
os.symlink(src, dst)
except OSError:
if os.name == 'nt': # os.symlink under Windows needs admin rights skip it
pass
os.makedirs(os.path.join(self.p.data_dir, 'foo'))
_symlink('foo', os.path.join(self.p.data_dir, 'pg_wal'))
os.makedirs(os.path.join(self.p.data_dir, 'foo_tsp'))
@@ -609,43 +596,36 @@ class TestPostgresql(BaseTestPostgresql):
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), None)
mock_cursor = Mock()
mock_cursor.fetchone.return_value = ('remote_apply',)
with patch.object(Postgresql, "query", side_effect=[
mock_cursor,
[(self.leadermem.name, 'streaming', 'sync'),
(self.me.name, 'streaming', 'async'),
(self.other.name, 'streaming', 'async')]
with patch.object(Postgresql, "query", return_value=[
(self.leadermem.name, 'streaming', 'sync'),
(self.me.name, 'streaming', 'async'),
(self.other.name, 'streaming', 'async'),
]):
self.assertEqual(self.p.pick_synchronous_standby(cluster), ([self.leadermem.name], [self.leadermem.name]))
self.assertEqual(self.p.pick_synchronous_standby(cluster), (self.leadermem.name, True))
with patch.object(Postgresql, "query", side_effect=[
mock_cursor,
[(self.leadermem.name, 'streaming', 'potential'),
(self.me.name, 'streaming', 'async'),
(self.other.name, 'streaming', 'async')]
with patch.object(Postgresql, "query", return_value=[
(self.me.name, 'streaming', 'async'),
(self.leadermem.name, 'streaming', 'potential'),
(self.other.name, 'streaming', 'async'),
]):
self.assertEqual(self.p.pick_synchronous_standby(cluster), ([self.leadermem.name], []))
self.assertEqual(self.p.pick_synchronous_standby(cluster), (self.leadermem.name, False))
with patch.object(Postgresql, "query", side_effect=[
mock_cursor,
[(self.me.name, 'streaming', 'async'),
(self.other.name, 'streaming', 'async')]
with patch.object(Postgresql, "query", return_value=[
(self.me.name, 'streaming', 'async'),
(self.other.name, 'streaming', 'async'),
]):
self.assertEqual(self.p.pick_synchronous_standby(cluster), ([self.me.name], []))
self.assertEqual(self.p.pick_synchronous_standby(cluster), (self.me.name, False))
with patch.object(Postgresql, "query", side_effect=[
mock_cursor,
[('missing', 'streaming', 'sync'),
(self.me.name, 'streaming', 'async'),
(self.other.name, 'streaming', 'async')]
with patch.object(Postgresql, "query", return_value=[
('missing', 'streaming', 'sync'),
(self.me.name, 'streaming', 'async'),
(self.other.name, 'streaming', 'async'),
]):
self.assertEqual(self.p.pick_synchronous_standby(cluster), ([self.me.name], []))
self.assertEqual(self.p.pick_synchronous_standby(cluster), (self.me.name, False))
with patch.object(Postgresql, "query", side_effect=[mock_cursor, []]):
self.p._major_version = 90400
self.assertEqual(self.p.pick_synchronous_standby(cluster), ([], []))
with patch.object(Postgresql, "query", return_value=[]):
self.assertEqual(self.p.pick_synchronous_standby(cluster), (None, False))
def test_set_sync_standby(self):
def value_in_conf():
@@ -655,21 +635,21 @@ class TestPostgresql(BaseTestPostgresql):
return line.strip()
mock_reload = self.p.reload = Mock()
self.p.config.set_synchronous_standby(['n1'])
self.p.config.set_synchronous_standby('n1')
self.assertEqual(value_in_conf(), "synchronous_standby_names = 'n1'")
mock_reload.assert_called()
mock_reload.reset_mock()
self.p.config.set_synchronous_standby(['n1'])
self.p.config.set_synchronous_standby('n1')
mock_reload.assert_not_called()
self.assertEqual(value_in_conf(), "synchronous_standby_names = 'n1'")
self.p.config.set_synchronous_standby(['n1', 'n2'])
self.p.config.set_synchronous_standby('n2')
mock_reload.assert_called()
self.assertEqual(value_in_conf(), "synchronous_standby_names = '2 (n1,n2)'")
self.assertEqual(value_in_conf(), "synchronous_standby_names = 'n2'")
mock_reload.reset_mock()
self.p.config.set_synchronous_standby([])
self.p.config.set_synchronous_standby(None)
mock_reload.assert_called()
self.assertEqual(value_in_conf(), None)
@@ -726,8 +706,3 @@ class TestPostgresql(BaseTestPostgresql):
self.p.set_role('standby_leader')
self.p.reset_cluster_info_state()
self.assertRaises(PostgresConnectionException, self.p.received_timeline)
def test__write_recovery_params(self):
self.p.config._write_recovery_params(Mock(), {'pause_at_recovery_target': 'false'})
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=90400)):
self.p.config._write_recovery_params(Mock(), {'recovery_target_action': 'PROMOTE'})
-155
View File
@@ -1,155 +0,0 @@
import os
import unittest
import time
from mock import Mock, patch
from patroni.dcs.raft import DynMemberSyncObj, KVStoreTTL, Raft, SyncObjUtility
from pysyncobj import SyncObjConf, FAIL_REASON
def remove_files(prefix):
for f in ('journal', 'dump'):
f = prefix + f
if os.path.isfile(f):
for i in range(0, 15):
try:
if os.path.isfile(f):
os.unlink(f)
break
else:
break
except Exception:
time.sleep(1.0)
@patch('pysyncobj.tcp_server.TcpServer.bind', Mock())
class TestDynMemberSyncObj(unittest.TestCase):
@patch('pysyncobj.tcp_server.TcpServer.bind', Mock())
def setUp(self):
self.conf = SyncObjConf(appendEntriesUseBatch=False, dynamicMembershipChange=True, autoTick=False)
self.so = DynMemberSyncObj('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf)
@patch.object(SyncObjUtility, 'sendMessage')
def test_add_member(self, mock_send_message):
mock_send_message.return_value = [{'addr': '127.0.0.1:1235'}, {'addr': '127.0.0.1:1236'}]
mock_send_message.ver = 0
DynMemberSyncObj('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf)
self.conf.dynamicMembershipChange = False
DynMemberSyncObj('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf)
def test___onUtilityMessage(self):
self.so._SyncObj__encryptor = Mock()
mock_conn = Mock()
mock_conn.sendRandKey = None
self.so._SyncObj__transport._onIncomingMessageReceived(mock_conn, 'randkey')
self.so._SyncObj__transport._onIncomingMessageReceived(mock_conn, ['members'])
self.so._SyncObj__transport._onIncomingMessageReceived(mock_conn, ['status'])
def test__SyncObj__doChangeCluster(self):
self.so._SyncObj__doChangeCluster(['add', '127.0.0.1:1236'])
def test_utility(self):
utility = SyncObjUtility(['127.0.0.1:1235'], self.conf)
utility.setPartnerNode(list(utility._SyncObj__otherNodes)[0])
utility.sendMessage(['members'])
utility._onMessageReceived(0, '')
class TestKVStoreTTL(unittest.TestCase):
def setUp(self):
self.conf = SyncObjConf(appendEntriesUseBatch=False, appendEntriesPeriod=0.001,
raftMinTimeout=0.004, raftMaxTimeout=0.005, autoTickPeriod=0.001)
callback = Mock()
callback.replicated = False
self.so = KVStoreTTL('127.0.0.1:1234', [], self.conf, on_set=callback, on_delete=callback)
self.so.set_retry_timeout(10)
@staticmethod
def destroy(so):
so.destroy()
so._SyncObj__thread.join()
def tearDown(self):
if self.so:
self.destroy(self.so)
def test_set(self):
self.assertTrue(self.so.set('foo', 'bar', prevExist=False, ttl=30))
self.assertFalse(self.so.set('foo', 'bar', prevExist=False, ttl=30))
self.assertFalse(self.so.retry(self.so._set, 'foo', {'value': 'buz', 'created': 1, 'updated': 1}, prevValue=''))
self.assertTrue(self.so.retry(self.so._set, 'foo', {'value': 'buz', 'created': 1, 'updated': 1}))
def test_delete(self):
self.conf.autoTickPeriod = 0.1
self.so.set('foo', 'bar')
self.so.set('fooo', 'bar')
self.assertFalse(self.so.delete('foo', prevValue='buz'))
self.assertFalse(self.so.delete('foo', prevValue='bar', timeout=0.00001))
self.assertFalse(self.so.delete('foo', prevValue='bar'))
self.assertTrue(self.so.delete('foo', recursive=True))
self.assertFalse(self.so.retry(self.so._delete, 'foo', prevValue=''))
def test_expire(self):
self.so.set('foo', 'bar', ttl=0.001)
time.sleep(1)
self.assertIsNone(self.so.get('foo'))
self.assertEqual(self.so.get('foo', recursive=True), {})
@patch('time.sleep', Mock())
def test_retry(self):
return_values = [FAIL_REASON.QUEUE_FULL, FAIL_REASON.SUCCESS, FAIL_REASON.REQUEST_DENIED]
def test(callback):
callback(True, return_values.pop(0))
self.assertTrue(self.so.retry(test))
self.assertFalse(self.so.retry(test))
def test_on_ready_override(self):
self.assertTrue(self.so.set('foo', 'bar'))
self.destroy(self.so)
self.so = None
self.conf.onReady = Mock()
self.conf.autoTick = False
so = KVStoreTTL('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf)
so.doTick(0)
so.destroy()
class TestRaft(unittest.TestCase):
def test_raft(self):
raft = Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'self_addr': '127.0.0.1:1234', 'retry_timeout': 10})
raft.set_retry_timeout(20)
raft.set_ttl(60)
self.assertTrue(raft.touch_member(''))
self.assertTrue(raft.initialize())
self.assertTrue(raft.cancel_initialization())
self.assertTrue(raft.set_config_value('{}'))
self.assertTrue(raft.write_sync_state('foo', 'bar'))
self.assertTrue(raft.update_leader('1'))
self.assertTrue(raft.manual_failover('foo', 'bar'))
raft.get_cluster()
self.assertTrue(raft.delete_sync_state())
self.assertTrue(raft.delete_leader())
self.assertTrue(raft.set_history_value(''))
self.assertTrue(raft.delete_cluster())
raft.get_cluster()
self.assertTrue(raft.take_leader())
raft.watch(None, 0.001)
raft._sync_obj.destroy()
raft._sync_obj._SyncObj__thread.join()
def tearDown(self):
remove_files('127.0.0.1:1234.')
def setUp(self):
self.tearDown()
@patch('patroni.dcs.raft.KVStoreTTL')
@patch('threading.Event')
def test_init(self, mock_event, mock_kvstore):
mock_kvstore.return_value.applied_local_log = False
mock_event.return_value.isSet.side_effect = [False, True]
self.assertIsNotNone(Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'patronictl': True}))
-44
View File
@@ -1,44 +0,0 @@
import logging
import os
import unittest
from mock import Mock, patch
from pysyncobj import SyncObj
from patroni.config import Config
from patroni.raft_controller import RaftController, main as _main
from . import SleepException
from .test_raft import remove_files
class TestPatroniRaftController(unittest.TestCase):
SELF_ADDR = '127.0.0.1:5360'
def remove_files(self):
remove_files(self.SELF_ADDR + '.')
@patch('pysyncobj.tcp_server.TcpServer.bind', Mock())
def setUp(self):
self._handlers = logging.getLogger().handlers[:]
self.remove_files()
os.environ['PATRONI_RAFT_SELF_ADDR'] = self.SELF_ADDR
config = Config('postgres0.yml', validator=None)
self.rc = RaftController(config)
def tearDown(self):
logging.getLogger().handlers[:] = self._handlers
self.remove_files()
def test_reload_config(self):
self.rc.reload_config()
@patch('logging.Logger.error', Mock(side_effect=SleepException))
@patch.object(SyncObj, 'doTick', Mock(side_effect=Exception))
def test_run(self):
self.assertRaises(SleepException, self.rc.run)
self.rc.shutdown()
@patch('sys.argv', ['patroni'])
def test_patroni_raft_controller_main(self):
self.assertRaises(SystemExit, _main)
+1 -9
View File
@@ -2,7 +2,7 @@ import unittest
from mock import Mock, patch
from patroni.exceptions import PatroniException
from patroni.utils import Retry, RetryFailedError, enable_keepalive, polling_loop, validate_directory
from patroni.utils import Retry, RetryFailedError, polling_loop, validate_directory
class TestUtils(unittest.TestCase):
@@ -33,14 +33,6 @@ class TestUtils(unittest.TestCase):
def test_validate_directory_is_not_a_directory(self):
self.assertRaises(PatroniException, validate_directory, "/tmp")
def test_enable_keepalive(self):
with patch('socket.SIO_KEEPALIVE_VALS', 1, create=True):
self.assertIsNotNone(enable_keepalive(Mock(), 10, 5))
with patch('socket.SIO_KEEPALIVE_VALS', None, create=True):
for platform in ('linux2', 'darwin', 'other'):
with patch('sys.platform', platform):
self.assertIsNone(enable_keepalive(Mock(), 10, 5))
@patch('time.sleep', Mock())
class TestRetrySleeper(unittest.TestCase):
-1
View File
@@ -153,7 +153,6 @@ class TestValidator(unittest.TestCase):
self.assertEqual(['etcd.hosts.1', 'etcd.hosts.2', 'kubernetes.pod_ip', 'postgresql.bin_dir',
'postgresql.data_dir', 'restapi.connect_address'], parse_output(output))
@patch('socket.inet_pton', Mock(), create=True)
def test_bin_dir_is_empty(self, mock_out, mock_err):
directories.append(config["postgresql"]["data_dir"])
directories.append(config["postgresql"]["bin_dir"])
-9
View File
@@ -37,8 +37,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'500', ZnodeStat(0, 0, 0, 0, 0, 0, 0, -1, 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))
@@ -155,12 +153,6 @@ class TestZooKeeper(unittest.TestCase):
cluster = self.zk.get_cluster(True)
self.assertIsInstance(cluster.leader, Leader)
self.zk.touch_member({'foo': 'foo'})
self.zk._name = 'bar'
self.zk.optime_watcher(None)
with patch.object(ZooKeeper, 'get_node', Mock(side_effect=Exception)):
self.zk.get_cluster()
cluster = self.zk.get_cluster()
self.assertEqual(cluster.last_leader_operation, 500)
def test_delete_leader(self):
self.assertTrue(self.zk.delete_leader())
@@ -221,7 +213,6 @@ class TestZooKeeper(unittest.TestCase):
def test_watch(self):
self.zk.watch(None, 0)
self.zk.event.isSet = Mock(return_value=True)
self.zk._fetch_optime = False
self.zk.watch(None, 0)
def test__kazoo_connect(self):