mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-30 16:19:24 +00:00
Compare commits
-13
@@ -1,13 +0,0 @@
|
|||||||
# for github.com
|
|
||||||
approvals:
|
|
||||||
groups:
|
|
||||||
zalando:
|
|
||||||
minimum: 2
|
|
||||||
from:
|
|
||||||
orgs:
|
|
||||||
- "zalando"
|
|
||||||
# team should be valid team id in team service https://teams.auth.zalando.com/api/teams/:id
|
|
||||||
X-Zalando-Team: "acid"
|
|
||||||
# type should be one of [code, doc, config, tools, secrets]
|
|
||||||
# code will be the default value, if X-Zalando-Type is not found in .zappr.yml
|
|
||||||
X-Zalando-Type: code
|
|
||||||
+9
-12
@@ -1,45 +1,42 @@
|
|||||||
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
|
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
|
||||||
## It has all the necessary components to play/debug with a single node appliance, running etcd
|
## It has all the necessary components to play/debug with a single node appliance, running etcd
|
||||||
FROM postgres:9.6
|
FROM postgres:10
|
||||||
MAINTAINER Alexander Kukushkin <[email protected]>
|
MAINTAINER Alexander Kukushkin <[email protected]>
|
||||||
|
|
||||||
RUN export DEBIAN_FRONTEND=noninteractive \
|
RUN export DEBIAN_FRONTEND=noninteractive \
|
||||||
&& echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";' > /etc/apt/apt.conf.d/01norecommend \
|
&& echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";' > /etc/apt/apt.conf.d/01norecommend \
|
||||||
&& apt-get update -y \
|
&& apt-get update -y \
|
||||||
&& apt-get upgrade -y \
|
&& apt-get upgrade -y \
|
||||||
&& apt-get install -y curl jq haproxy python-psycopg2 python-yaml python-requests python-six python-pysocks \
|
# postgres:10 is based on debian, which has patroni package. We will install all required dependencies
|
||||||
python-dateutil python-pip python-setuptools python-prettytable python-wheel python-psutil python locales \
|
&& apt-get install -s patroni | sed -n -e '/^Inst patroni /d' -e 's/^Inst \([^ ]\+\) .*$/\1/p' \
|
||||||
|
| xargs apt-get install -y curl jq haproxy locales python3-etcd python3-kazoo \
|
||||||
|
|
||||||
## Make sure we have a en_US.UTF-8 locale available
|
## Make sure we have a en_US.UTF-8 locale available
|
||||||
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
|
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
|
||||||
|
|
||||||
&& pip install 'python-etcd>=0.4.3,<0.5' click tzlocal cdiff \
|
|
||||||
|
|
||||||
&& mkdir -p /home/postgres \
|
&& mkdir -p /home/postgres \
|
||||||
&& chown postgres:postgres /home/postgres \
|
&& chown postgres:postgres /home/postgres \
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
&& apt-get remove -y python-pip python-setuptools \
|
&& apt-get purge -y libpython2.7-stdlib libpython2.7-minimal \
|
||||||
&& apt-get autoremove -y \
|
&& apt-get autoremove -y \
|
||||||
&& apt-get clean -y \
|
&& apt-get clean -y \
|
||||||
&& rm -rf /var/lib/apt/lists/* /root/.cache
|
&& rm -rf /var/lib/apt/lists/* /root/.cache
|
||||||
|
|
||||||
ENV ETCDVERSION 3.2.3
|
ENV ETCDVERSION 3.2.23
|
||||||
RUN curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz \
|
RUN curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz \
|
||||||
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl
|
| tar xz -C /usr/local/bin --strip=1 --wildcards --no-anchored etcd etcdctl
|
||||||
|
|
||||||
ENV CONFDVERSION 0.11.0
|
ENV CONFDVERSION 0.16.0
|
||||||
RUN curl -L https://github.com/kelseyhightower/confd/releases/download/v${CONFDVERSION}/confd-${CONFDVERSION}-linux-amd64 > /usr/local/bin/confd \
|
RUN curl -L https://github.com/kelseyhightower/confd/releases/download/v${CONFDVERSION}/confd-${CONFDVERSION}-linux-amd64 > /usr/local/bin/confd \
|
||||||
&& chmod +x /usr/local/bin/confd
|
&& chmod +x /usr/local/bin/confd
|
||||||
|
|
||||||
ADD patronictl.py patroni.py docker/entrypoint.sh /
|
ADD patronictl.py patroni.py docker/entrypoint.sh /
|
||||||
ADD patroni /patroni/
|
ADD patroni /patroni/
|
||||||
ADD extras/confd /etc/confd
|
ADD extras/confd /etc/confd
|
||||||
RUN ln -s /patronictl.py /usr/local/bin/patronictl
|
|
||||||
|
|
||||||
### Setting up a simple script that will serve as an entrypoint
|
RUN sed -i 's/env python/&3/' patroni*.py && ln -s /patronictl.py /usr/local/bin/patronictl && mkdir /data/ /run/haproxy \
|
||||||
RUN mkdir /data/ && touch /pgpass /patroni.yml \
|
&& touch /pgpass /patroni.yml && chown postgres:postgres -R /patroni/ /data/ /pgpass /patroni.yml /etc/haproxy /var/run/ /var/lib/ /var/log/
|
||||||
&& chown postgres:postgres -R /patroni/ /data/ /pgpass /patroni.yml /etc/haproxy /var/run/ /var/lib/ /var/log/
|
|
||||||
|
|
||||||
EXPOSE 2379 5432 8008
|
EXPOSE 2379 5432 8008
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -12,7 +12,7 @@ Patroni is a template for you to create your own customized, high-availability s
|
|||||||
|
|
||||||
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely.
|
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely.
|
||||||
|
|
||||||
**Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the `Kubernetes <https://github.com/zalando/patroni/blog/master/docs/kubernetes.rst>`__ chapter of the Patroni documentation.
|
**Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the `Kubernetes <https://github.com/zalando/patroni/blob/master/docs/kubernetes.rst>`__ chapter of the Patroni documentation.
|
||||||
|
|
||||||
.. contents::
|
.. contents::
|
||||||
:local:
|
:local:
|
||||||
@@ -29,7 +29,7 @@ For an example of a Docker-based deployment with Patroni, see `Spilo <https://gi
|
|||||||
|
|
||||||
For additional background info, see:
|
For additional background info, see:
|
||||||
|
|
||||||
* `Elephants on Automatic: HA Clustered PostgreSQL with Helm <https://www.youtube.com/watch?v=CftcVhFMGSY>`_, talk by Josh Berkus and Oleksii Kliukin at KubeCon Berlin 2017
|
* `Elephants on Automatic: HA Clustered PostgreSQL with Helm <https://www.youtube.com/watch?v=CftcVhFMGSY>`_, talk by Josh Berkus and Oleksii Kliukin at KubeCon Berlin 2017
|
||||||
* `PostgreSQL HA with Kubernetes and Patroni <https://www.youtube.com/watch?v=iruaCgeG7qs>`__, talk by Josh Berkus at KubeCon 2016 (video)
|
* `PostgreSQL HA with Kubernetes and Patroni <https://www.youtube.com/watch?v=iruaCgeG7qs>`__, talk by Josh Berkus at KubeCon 2016 (video)
|
||||||
* `Feb. 2016 Zalando Tech blog post <https://tech.zalando.de/blog/zalandos-patroni-a-template-for-high-availability-postgresql/>`__
|
* `Feb. 2016 Zalando Tech blog post <https://tech.zalando.de/blog/zalandos-patroni-a-template-for-high-availability-postgresql/>`__
|
||||||
|
|
||||||
@@ -41,6 +41,12 @@ Patroni is in active development and accepts contributions. See our `Contributin
|
|||||||
|
|
||||||
We report new releases information `here <https://github.com/zalando/patroni/releases>`__.
|
We report new releases information `here <https://github.com/zalando/patroni/releases>`__.
|
||||||
|
|
||||||
|
=========
|
||||||
|
Community
|
||||||
|
=========
|
||||||
|
|
||||||
|
There are two places to connect with the Patroni community: `on github <https://github.com/zalando/patroni>`__, via Issues and PRs, and on channel #patroni in the `PostgreSQL Slack <https://postgres-slack.herokuapp.com/>`__. If you're using Patroni, or just interested, please join us.
|
||||||
|
|
||||||
===================================
|
===================================
|
||||||
Technical Requirements/Installation
|
Technical Requirements/Installation
|
||||||
===================================
|
===================================
|
||||||
|
|||||||
+4
-4
@@ -15,7 +15,7 @@ dbnode1:
|
|||||||
- ./patroni:/patroni
|
- ./patroni:/patroni
|
||||||
env_file: docker/patroni-secrets.env
|
env_file: docker/patroni-secrets.env
|
||||||
environment:
|
environment:
|
||||||
PATRONI_ETCD_HOST: patroni_etcd:2379
|
PATRONI_ETCD_URL: http://patroni_etcd:2379
|
||||||
PATRONI_NAME: dbnode1
|
PATRONI_NAME: dbnode1
|
||||||
PATRONI_SCOPE: testcluster
|
PATRONI_SCOPE: testcluster
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ dbnode2:
|
|||||||
- ./patroni:/patroni
|
- ./patroni:/patroni
|
||||||
env_file: docker/patroni-secrets.env
|
env_file: docker/patroni-secrets.env
|
||||||
environment:
|
environment:
|
||||||
PATRONI_ETCD_HOST: patroni_etcd:2379
|
PATRONI_ETCD_URL: http://patroni_etcd:2379
|
||||||
PATRONI_NAME: dbnode2
|
PATRONI_NAME: dbnode2
|
||||||
PATRONI_SCOPE: testcluster
|
PATRONI_SCOPE: testcluster
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ dbnode3:
|
|||||||
- ./patroni:/patroni
|
- ./patroni:/patroni
|
||||||
env_file: docker/patroni-secrets.env
|
env_file: docker/patroni-secrets.env
|
||||||
environment:
|
environment:
|
||||||
PATRONI_ETCD_HOST: patroni_etcd:2379
|
PATRONI_ETCD_URL: http://patroni_etcd:2379
|
||||||
PATRONI_NAME: dbnode3
|
PATRONI_NAME: dbnode3
|
||||||
PATRONI_SCOPE: testcluster
|
PATRONI_SCOPE: testcluster
|
||||||
|
|
||||||
@@ -53,6 +53,6 @@ haproxy:
|
|||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
- "5001:5001"
|
- "5001:5001"
|
||||||
environment:
|
environment:
|
||||||
PATRONI_ETCD_HOST: patroni_etcd:2379
|
PATRONI_ETCD_URL: http://patroni_etcd:2379
|
||||||
PATRONI_SCOPE: testcluster
|
PATRONI_SCOPE: testcluster
|
||||||
command: --confd
|
command: --confd
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function docker_run()
|
|||||||
ETCD_CONTAINER="${PATRONI_SCOPE}_etcd"
|
ETCD_CONTAINER="${PATRONI_SCOPE}_etcd"
|
||||||
docker_run ${ETCD_CONTAINER} ${DOCKER_IMAGE} --etcd
|
docker_run ${ETCD_CONTAINER} ${DOCKER_IMAGE} --etcd
|
||||||
|
|
||||||
DOCKER_ARGS="--link=${ETCD_CONTAINER}:${ETCD_CONTAINER} -e PATRONI_SCOPE=${PATRONI_SCOPE} -e PATRONI_ETCD_HOST=${ETCD_CONTAINER}:2379"
|
DOCKER_ARGS="--link=${ETCD_CONTAINER}:${ETCD_CONTAINER} -e PATRONI_SCOPE=${PATRONI_SCOPE} -e PATRONI_ETCD_URL=http://${ETCD_CONTAINER}:2379"
|
||||||
PATRONI_ENV=$(sed 's/#.*//g' docker/patroni-secrets.env | sed -n 's/^PATRONI_.*$/-e &/p' | tr '\n' ' ')
|
PATRONI_ENV=$(sed 's/#.*//g' docker/patroni-secrets.env | sed -n 's/^PATRONI_.*$/-e &/p' | tr '\n' ' ')
|
||||||
PATRONI_VOLUME="-v $(dirname $(dirname $(realpath $0)))/patroni:/patroni"
|
PATRONI_VOLUME="-v $(dirname $(dirname $(realpath $0)))/patroni:/patroni"
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ while getopts "$optspec" optchar; do
|
|||||||
done
|
done
|
||||||
exec $CONFD zookeeper -node ${PATRONI_ZOOKEEPER_HOSTS}
|
exec $CONFD zookeeper -node ${PATRONI_ZOOKEEPER_HOSTS}
|
||||||
else
|
else
|
||||||
while ! curl -s ${PATRONI_ETCD_HOST}/v2/members | jq -r '.members[0].clientURLs[0]' | grep -q http; do
|
while ! curl -s ${PATRONI_ETCD_URL}/v2/members | jq -r '.members[0].clientURLs[0]' | grep -q http; do
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
exec $CONFD etcd -node $PATRONI_ETCD_HOST
|
exec $CONFD etcd -node $PATRONI_ETCD_URL
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
etcd)
|
etcd)
|
||||||
@@ -74,9 +74,9 @@ while getopts "$optspec" optchar; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
## We start an etcd
|
## We start an etcd
|
||||||
if [[ -z ${PATRONI_ETCD_HOST} && -z ${PATRONI_ZOOKEEPER_HOSTS} ]]; then
|
if [[ -z ${PATRONI_ETCD_URL} && -z ${PATRONI_ZOOKEEPER_HOSTS} ]]; then
|
||||||
etcd $ETCD_ARGS > /var/log/etcd.log 2> /var/log/etcd.err &
|
etcd $ETCD_ARGS > /var/log/etcd.log 2> /var/log/etcd.err &
|
||||||
export PATRONI_ETCD_HOST="127.0.0.1:2379"
|
export PATRONI_ETCD_URL="http://127.0.0.1:2379"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export PATRONI_SCOPE
|
export PATRONI_SCOPE
|
||||||
@@ -108,7 +108,7 @@ __EOF__
|
|||||||
mkdir -p "$HOME/.config/patroni"
|
mkdir -p "$HOME/.config/patroni"
|
||||||
[ -h "$HOME/.config/patroni/patronictl.yaml" ] || ln -s /patroni.yml "$HOME/.config/patroni/patronictl.yaml"
|
[ -h "$HOME/.config/patroni/patronictl.yaml" ] || ln -s /patroni.yml "$HOME/.config/patroni/patronictl.yaml"
|
||||||
|
|
||||||
[ -z $CHEAT ] && exec python /patroni.py /patroni.yml
|
[ -z $CHEAT ] && exec python3 /patroni.py /patroni.yml
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
sleep 60
|
sleep 60
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ Contributing guidelines
|
|||||||
|
|
||||||
Wanna contribute to Patroni? Yay - here is how!
|
Wanna contribute to Patroni? Yay - here is how!
|
||||||
|
|
||||||
|
Chatting
|
||||||
|
--------
|
||||||
|
|
||||||
|
Just want to chat with other Patroni users? Looking for interactive troubleshooting help? Join us on channel #patroni in the `PostgreSQL Slack <https://postgres-slack.herokuapp.com/>`__.
|
||||||
|
|
||||||
Reporting issues
|
Reporting issues
|
||||||
----------------
|
----------------
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ Global/Universal
|
|||||||
- **PATRONI\_NAME**: name of the node where the current instance of Patroni is running. Must be unique for the cluster.
|
- **PATRONI\_NAME**: name of the node where the current instance of Patroni is running. Must be unique for the cluster.
|
||||||
- **PATRONI\_NAMESPACE**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
|
- **PATRONI\_NAMESPACE**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
|
||||||
- **PATRONI\_SCOPE**: cluster name
|
- **PATRONI\_SCOPE**: cluster name
|
||||||
|
- **PATRONI\_LOGLEVEL**: sets the general logging level (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
|
||||||
|
- **PATRONI\_REQUESTS_LOGLEVEL**: sets the logging level for all HTTP requests e.g. Kubernetes API calls (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
|
||||||
|
|
||||||
Bootstrap configuration
|
Bootstrap configuration
|
||||||
-----------------------
|
-----------------------
|
||||||
@@ -29,7 +31,7 @@ Consul
|
|||||||
- **PATRONI\_CONSUL\_SCHEME**: (optional) **http** or **https**, defaults to **http**
|
- **PATRONI\_CONSUL\_SCHEME**: (optional) **http** or **https**, defaults to **http**
|
||||||
- **PATRONI\_CONSUL\_TOKEN**: (optional) ACL token
|
- **PATRONI\_CONSUL\_TOKEN**: (optional) ACL token
|
||||||
- **PATRONI\_CONSUL\_VERIFY**: (optional) whether to verify the SSL certificate for HTTPS requests
|
- **PATRONI\_CONSUL\_VERIFY**: (optional) whether to verify the SSL certificate for HTTPS requests
|
||||||
- **PATRONI\_CONSUL\_CACERT**: (optional) The ca certificate. If pressent it will enable validation.
|
- **PATRONI\_CONSUL\_CACERT**: (optional) The ca certificate. If present it will enable validation.
|
||||||
- **PATRONI\_CONSUL\_CERT**: (optional) File with the client certificate
|
- **PATRONI\_CONSUL\_CERT**: (optional) File with the client certificate
|
||||||
- **PATRONI\_CONSUL\_KEY**: (optional) File with the client key. Can be empty if the key is part of certificate.
|
- **PATRONI\_CONSUL\_KEY**: (optional) File with the client key. Can be empty if the key is part of certificate.
|
||||||
- **PATRONI\_CONSUL\_DC**: (optional) Datacenter to communicate with. By default the datacenter of the host is used.
|
- **PATRONI\_CONSUL\_DC**: (optional) Datacenter to communicate with. By default the datacenter of the host is used.
|
||||||
@@ -42,7 +44,7 @@ Etcd
|
|||||||
- **PATRONI\_ETCD\_URL**: url for the etcd, in format: http(s)://(username:password@)host:port
|
- **PATRONI\_ETCD\_URL**: url for the etcd, in format: http(s)://(username:password@)host:port
|
||||||
- **PATRONI\_ETCD\_PROXY**: proxy url for the etcd. If you are connecting to the etcd using proxy, use this parameter instead of **PATRONI\_ETCD\_URL**
|
- **PATRONI\_ETCD\_PROXY**: proxy url for the etcd. If you are connecting to the etcd using proxy, use this parameter instead of **PATRONI\_ETCD\_URL**
|
||||||
- **PATRONI\_ETCD\_SRV**: Domain to search the SRV record(s) for cluster autodiscovery.
|
- **PATRONI\_ETCD\_SRV**: Domain to search the SRV record(s) for cluster autodiscovery.
|
||||||
- **PATRONI\_ETCD\_CACERT**: The ca certificate. If pressent it will enable validation.
|
- **PATRONI\_ETCD\_CACERT**: The ca certificate. If present it will enable validation.
|
||||||
- **PATRONI\_ETCD\_CERT**: File with the client certificate
|
- **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.
|
- **PATRONI\_ETCD\_KEY**: File with the client key. Can be empty if the key is part of certificate.
|
||||||
|
|
||||||
@@ -55,13 +57,13 @@ Exhibitor
|
|||||||
|
|
||||||
Kubernetes
|
Kubernetes
|
||||||
----------
|
----------
|
||||||
- **PATRONI\_KUBERNETES\_NAMESPACE**: (optional) Kubernetes namespace where the operator is running. Default value is `default`.
|
- **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\_LABELS**: Labels in format ``{label1: value1, label2: value2}``. These labels will be used to find existing objects (Pods and either Endpoints or ConfigMaps) associated with the current cluster. Also Patroni will set them on every object (Endpoint or ConfigMap) it creates.
|
||||||
- **PATRONI\_KUBERNETES\_SCOPE\_LABEL**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
|
- **PATRONI\_KUBERNETES\_SCOPE\_LABEL**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
|
||||||
- **PATRONI\_KUBERNETES\_ROLE\_LABEL**: (optional) name of the label containing Postgres role (`master` or `replica`). Patroni will set this label on the pod it is running in. Default value is `role`.
|
- **PATRONI\_KUBERNETES\_ROLE\_LABEL**: (optional) name of the label containing Postgres role (`master` or `replica`). Patroni will set this label on the pod it is running in. Default value is `role`.
|
||||||
- **PATRONI\_KUBERNETES\_USE\_ENDPOINTS**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
|
- **PATRONI\_KUBERNETES\_USE\_ENDPOINTS**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
|
||||||
- **PATRONI\_KUBERNETES\_POD\_IP**: (optional) IP address of the pod Patroni is running in. This value is required when `PATRONI_KUBERNETES_USE_ENDPOINTS` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
|
- **PATRONI\_KUBERNETES\_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 wont 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.
|
- **PATRONI\_KUBERNETES\_PORTS**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``PATRONI_KUBERNETES_PORTS='{[{"name": "postgresql", "port": 5432}]}'`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `PATRONI_KUBERNETES_USE_ENDPOINTS` is set.
|
||||||
|
|
||||||
PostgreSQL
|
PostgreSQL
|
||||||
----------
|
----------
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@ For example, the command in order to install Patroni together with dependencies
|
|||||||
|
|
||||||
pip install patroni[etcd,aws]
|
pip install patroni[etcd,aws]
|
||||||
|
|
||||||
Note that external tools to call in the replica creation or custom bootstap scripts (i.e. WAL-E) should be installed
|
Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed
|
||||||
independently of Patroni.
|
independently of Patroni.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+43
-16
@@ -18,13 +18,27 @@ Bootstrap configuration
|
|||||||
- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries. DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10
|
- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries. DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10
|
||||||
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
|
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
|
||||||
- **master\_start\_timeout**: the amount of time a master is allowed to recover from failures before failover is triggered. Default is 300 seconds. When set to 0 failover is done immediately after a crash is detected if possible. When using asynchronous replication a failover can cause lost transactions. Best worst case failover time for master failure is: loop\_wait + master\_start\_timeout + loop\_wait, unless master\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
|
- **master\_start\_timeout**: the amount of time a master is allowed to recover from failures before failover is triggered. Default is 300 seconds. When set to 0 failover is done immediately after a crash is detected if possible. When using asynchronous replication a failover can cause lost transactions. Best worst case failover time for master failure is: loop\_wait + master\_start\_timeout + loop\_wait, unless master\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
|
||||||
- **synchronous\_mode**: turns on synchronous replication mode. In this mode a replica will be chosen as synchronous and only the latest leader and synchronous replica are able to participate in leader election. Synchronous mode makes sure that succesfully committed transactions will not be lost at failover, at the cost of losing availability for writes when Patroni cannot ensure transaction durability. See `replication modes documentation <https://github.com/zalando/patroni/blob/master/docs/replication_modes.rst>`__ for details.
|
- **synchronous\_mode**: turns on synchronous replication mode. In this mode a replica will be chosen as synchronous and only the latest leader and synchronous replica are able to participate in leader election. Synchronous mode makes sure that successfully committed transactions will not be lost at failover, at the cost of losing availability for writes when Patroni cannot ensure transaction durability. See :ref:`replication modes documentation <replication_modes>` for details.
|
||||||
|
- **synchronous\_mode\_strict**: prevents disabling synchronous replication if no synchronous replicas are available, blocking all client writes to the master. See :ref:`replication modes documentation <replication_modes>` for details.
|
||||||
- **postgresql**:
|
- **postgresql**:
|
||||||
- **use\_pg\_rewind**:whether or not to use pg_rewind
|
- **use\_pg\_rewind**: whether or not to use pg_rewind
|
||||||
- **use\_slots**: whether or not to use replication_slots. Must be False for PostgreSQL 9.3. You should comment out max_replication_slots before it becomes ineligible for leader status.
|
- **use\_slots**: whether or not to use replication_slots. Must be False for PostgreSQL 9.3. You should comment out max_replication_slots before it becomes ineligible for leader status.
|
||||||
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower.
|
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower.
|
||||||
- **parameters**: list of configuration settings for Postgres. Many of these are required for replication to work.
|
- **parameters**: list of configuration settings for Postgres. Many of these are required for replication to work.
|
||||||
- **method**: custom script to use for bootstrpapping this cluster.
|
- **standby\_cluster**: if this section is defined, we want to bootstrap a standby cluster.
|
||||||
|
- **host**: an address of remote master
|
||||||
|
- **port**: a port of remote master
|
||||||
|
- **primary\_slot\_name**: which slot on the remote master to use for replication. This parameter is optional, the default value is derived from the instance name (see function `slot_name_from_member_name`).
|
||||||
|
- **create\_replica\_methods**: an ordered list of methods that can be used to bootstrap standby leader from the remote master, can be different from the list defined in :ref:`postgresql_settings`
|
||||||
|
- **restore\_command**: command to restore WAL records from the remote master to standby leader, can be different from the list defined in :ref:`postgresql_settings`
|
||||||
|
- **archive\_cleanup\_command**: cleanup command for standby leader
|
||||||
|
- **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader
|
||||||
|
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Patroni will try to create slots before opening connections to the cluster.
|
||||||
|
- **my_slot_name**: the name of replication slot. It is the responsibility of the operator to make sure that there are no clashes in names between replication slots automatically created by Patroni for members and permanent replication slots.
|
||||||
|
- **type**: slot type. Could be ``physical`` or ``logical``. If the slot is logical, you have to additionally define ``database`` and ``plugin``.
|
||||||
|
**database**: the database name where logical slots should be created.
|
||||||
|
**plugin**: the plugin name for the logical slot.
|
||||||
|
- **method**: custom script to use for bootstrapping this cluster.
|
||||||
See :ref:`custom bootstrap methods documentation <custom_bootstrap>` for details.
|
See :ref:`custom bootstrap methods documentation <custom_bootstrap>` for details.
|
||||||
When ``initdb`` is specified revert to the default ``initdb`` command. ``initdb`` is also triggered when no ``method``
|
When ``initdb`` is specified revert to the default ``initdb`` command. ``initdb`` is also triggered when no ``method``
|
||||||
parameter is present in the configuration file.
|
parameter is present in the configuration file.
|
||||||
@@ -35,7 +49,7 @@ Bootstrap configuration
|
|||||||
- **pg\_hba**: list of lines that you should add to pg\_hba.conf.
|
- **pg\_hba**: list of lines that you should add to pg\_hba.conf.
|
||||||
- **- host all all 0.0.0.0/0 md5**.
|
- **- host all all 0.0.0.0/0 md5**.
|
||||||
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
|
- **- host replication replicator 127.0.0.1/32 md5**: A line like this is required for replication.
|
||||||
- **users**: Some additional users users which needs to be created after initializing new cluster
|
- **users**: Some additional users which need to be created after initializing new cluster
|
||||||
- **admin**: the name of user
|
- **admin**: the name of user
|
||||||
- **password: zalando**:
|
- **password: zalando**:
|
||||||
- **options**: list of options for CREATE USER statement
|
- **options**: list of options for CREATE USER statement
|
||||||
@@ -54,12 +68,14 @@ Most of the parameters are optional, but you have to specify one of the **host**
|
|||||||
- **port**: (optional) Consul port
|
- **port**: (optional) Consul port
|
||||||
- **scheme**: (optional) **http** or **https**, defaults to **http**
|
- **scheme**: (optional) **http** or **https**, defaults to **http**
|
||||||
- **token**: (optional) ACL token
|
- **token**: (optional) ACL token
|
||||||
- **verify** (optional) whether to verify the SSL certificate for HTTPS requests
|
- **verify**: (optional) whether to verify the SSL certificate for HTTPS requests
|
||||||
- **cacert**: (optional) The ca certificate. If pressent it will enable validation.
|
- **cacert**: (optional) The ca certificate. If present it will enable validation.
|
||||||
- **cert**: (optional) file with the client certificate
|
- **cert**: (optional) file with the client certificate
|
||||||
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
|
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
|
||||||
- **dc**: (optional) Datacenter to communicate with. By default the datacenter of the host is used.
|
- **dc**: (optional) Datacenter to communicate with. By default the datacenter of the host is used.
|
||||||
- **checks**: (optional) list of Consul health checks used for the session. If not specified Consul will use "serfHealth" in additional to the TTL based check created by Patroni. Additional checks, in particular the "serfHealth", may cause the leader lock to expire faster than in `ttl` seconds when the leader instance becomes unavailable
|
- **checks**: (optional) list of Consul health checks used for the session. If not specified Consul will use "serfHealth" in additional to the TTL based check created by Patroni. Additional checks, in particular the "serfHealth", may cause the leader lock to expire faster than in `ttl` seconds when the leader instance becomes unavailable
|
||||||
|
- **register\_service**: (optional) whether or not to register a service with the name defined by the scope parameter and the tag master, replica or standby-leader depending on the node's role. Defaults to **false**
|
||||||
|
- **service\_check\_interval**: (optional) how often to perform health check against registered url
|
||||||
|
|
||||||
Etcd
|
Etcd
|
||||||
----
|
----
|
||||||
@@ -73,7 +89,7 @@ Most of the parameters are optional, but you have to specify one of the **host**
|
|||||||
- **protocol**: (optional) http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
|
- **protocol**: (optional) http or https, if not specified http is used. If the **url** or **proxy** is specified - will take protocol from them.
|
||||||
- **username**: (optional) username for etcd authentication
|
- **username**: (optional) username for etcd authentication
|
||||||
- **password**: (optional) password for etcd authentication.
|
- **password**: (optional) password for etcd authentication.
|
||||||
- **cacert**: (optional) The ca certificate. If pressent it will enable validation.
|
- **cacert**: (optional) The ca certificate. If present it will enable validation.
|
||||||
- **cert**: (optional) file with the client certificate
|
- **cert**: (optional) file with the client certificate
|
||||||
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
|
- **key**: (optional) file with the client key. Can be empty if the key is part of **cert**.
|
||||||
|
|
||||||
@@ -87,13 +103,13 @@ Exhibitor
|
|||||||
|
|
||||||
Kubernetes
|
Kubernetes
|
||||||
----------
|
----------
|
||||||
- **namespace**: (optional) Kubernetes namespace where we are running. Default value is `default`.
|
- **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.
|
- **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`.
|
- **scope\_label**: (optional) name of the label containing cluster name. Default value is `cluster-name`.
|
||||||
- **role\_label**: (optional) name of the label containing role (master or replica). Patroni will set this label on the pod it runs in. Default value is ``role``.
|
- **role\_label**: (optional) name of the label containing role (master or replica). Patroni will set this label on the pod it runs in. Default value is ``role``.
|
||||||
- **use\_endpoints**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
|
- **use\_endpoints**: (optional) if set to true, Patroni will use Endpoints instead of ConfigMaps to run leader elections and keep cluster state.
|
||||||
- **pod\_ip**: (optional) IP address of the pod Patroni is running in. This value is required when `use_endpoints` is enabled and is used to populate the leader endpoint subsets when the pod's PostgreSQL is promoted.
|
- **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 wont 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.
|
- **ports**: (optional) if the Service object has the name for the port, the same name must appear in the Endpoint object, otherwise service won't work. For example, if your service is defined as ``{Kind: Service, spec: {ports: [{name: postgresql, port: 5432, targetPort: 5432}]}}``, then you have to set ``kubernetes.ports: {[{"name": "postgresql", "port": 5432}]}`` and Patroni will use it for updating subsets of the leader Endpoint. This parameter is used only if `kubernetes.use_endpoints` is set.
|
||||||
|
|
||||||
.. _postgresql_settings:
|
.. _postgresql_settings:
|
||||||
|
|
||||||
@@ -113,17 +129,17 @@ PostgreSQL
|
|||||||
- **on\_start**: run this script when the cluster starts.
|
- **on\_start**: run this script when the cluster starts.
|
||||||
- **on\_stop**: run this script when the cluster stops.
|
- **on\_stop**: run this script when the cluster stops.
|
||||||
- **connect\_address**: IP address + port through which Postgres is accessible from other nodes and applications.
|
- **connect\_address**: IP address + port through which Postgres is accessible from other nodes and applications.
|
||||||
- **create\_replica\_method**: an ordered list of the create methods for turning a Patroni node into a new replica.
|
- **create\_replica\_methods**: an ordered list of the create methods for turning a Patroni node into a new replica.
|
||||||
"basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured as its
|
"basebackup" is the default method; other methods are assumed to refer to scripts, each of which is configured as its
|
||||||
own config item. See :ref:`custom replica creation methods documentation <custom_replica_creation>` for further explanation.
|
own config item. See :ref:`custom replica creation methods documentation <custom_replica_creation>` for further explanation.
|
||||||
- **data\_dir**: The location of the Postgres data directory, either existing or to be initialized by Patroni.
|
- **data\_dir**: The location of the Postgres data directory, either existing or to be initialized by Patroni.
|
||||||
- **config\_dir**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable by Patroni.
|
- **config\_dir**: The location of the Postgres configuration directory, defaults to the data directory. Must be writable by Patroni.
|
||||||
- **bin\_dir**: Path to PostgreSQL binaries. (pg_ctl, pg_rewind, pg_basebackup, postgres) The default value is an empty string meaning that PATH environment variable will be used to find the executables.
|
- **bin\_dir**: Path to PostgreSQL binaries (pg_ctl, pg_rewind, pg_basebackup, postgres). The default value is an empty string meaning that PATH environment variable will be used to find the executables.
|
||||||
- **listen**: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node.
|
- **listen**: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node.
|
||||||
- **use\_unix\_socket**: specifies that Patroni should prefer to use unix sockets to connect to the cluster. Default value is ``false``. If ``unix_socket_directories`` is definded, Patroni will use first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that default value should be used and omit ``host`` from connection parameters.
|
- **use\_unix\_socket**: specifies that Patroni should prefer to use unix sockets to connect to the cluster. Default value is ``false``. If ``unix_socket_directories`` is definded, Patroni will use first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that default value should be used and omit ``host`` from connection parameters.
|
||||||
- **pgpass**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup, the post_init script and under some other circumstances. The location must be writable by Patroni.
|
- **pgpass**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup, the post_init script and under some other circumstances. The location must be writable by Patroni.
|
||||||
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower.
|
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower.
|
||||||
- **custom\_conf** : path to an optional custom ``postgresql.conf`` file, that will be used in place of ``postgresql.base.conf``. The file must exist on all cluster nodes, be readable by PostgreSQL and will be included from its location on the real ``postgresql.conf``. Note that Patroni will not monitor this file for changes, nor backup it. However, its settings can still be overriden by Patroni's own configuration facilities - see `dynamic configuration <https://github.com/zalando/patroni/blob/master/docs/dynamic_configuration.rst>`__ for details.
|
- **custom\_conf** : path to an optional custom ``postgresql.conf`` file, that will be used in place of ``postgresql.base.conf``. The file must exist on all cluster nodes, be readable by PostgreSQL and will be included from its location on the real ``postgresql.conf``. Note that Patroni will not monitor this file for changes, nor backup it. However, its settings can still be overridden by Patroni's own configuration facilities - see :ref:`dynamic configuration <dynamic_configuration>` for details.
|
||||||
- **parameters**: list of configuration settings for Postgres. Many of these are required for replication to work.
|
- **parameters**: list of configuration settings for Postgres. Many of these are required for replication to work.
|
||||||
- **pg\_hba**: list of lines that Patroni will use to generate ``pg_hba.conf``. This parameter has higher priority than ``bootstrap.pg_hba``. Together with :ref:`dynamic configuration <dynamic_configuration>` it simplifies management of ``pg_hba.conf``.
|
- **pg\_hba**: list of lines that Patroni will use to generate ``pg_hba.conf``. This parameter has higher priority than ``bootstrap.pg_hba``. Together with :ref:`dynamic configuration <dynamic_configuration>` it simplifies management of ``pg_hba.conf``.
|
||||||
- **- host all all 0.0.0.0/0 md5**.
|
- **- host all all 0.0.0.0/0 md5**.
|
||||||
@@ -131,12 +147,14 @@ PostgreSQL
|
|||||||
- **pg\_ctl\_timeout**: How long should pg_ctl wait when doing ``start``, ``stop`` or ``restart``. Default value is 60 seconds.
|
- **pg\_ctl\_timeout**: How long should pg_ctl wait when doing ``start``, ``stop`` or ``restart``. Default value is 60 seconds.
|
||||||
- **use\_pg\_rewind**: try to use pg\_rewind on the former leader when it joins cluster as a replica.
|
- **use\_pg\_rewind**: try to use pg\_rewind on the former leader when it joins cluster as a replica.
|
||||||
- **remove\_data\_directory\_on\_rewind\_failure**: If this option is enabled, Patroni will remove postgres data directory and recreate replica. Otherwise it will try to follow the new leader. Default value is **false**.
|
- **remove\_data\_directory\_on\_rewind\_failure**: If this option is enabled, Patroni will remove postgres data directory and recreate replica. Otherwise it will try to follow the new leader. Default value is **false**.
|
||||||
- **replica\_method** for each create_replica_method other than basebackup, you would add a configuration section of the same name. At a minimum, this should include "command" with a full path to the actual script to be executed. Other configuration parameters will be passed along to the script in the form "parameter=value".
|
- **replica\_method**: for each create_replica_methods other than basebackup, you would add a configuration section of the same name. At a minimum, this should include "command" with a full path to the actual script to be executed. Other configuration parameters will be passed along to the script in the form "parameter=value".
|
||||||
|
|
||||||
REST API
|
REST API
|
||||||
--------
|
--------
|
||||||
- **connect\_address**: IP address and port to access the REST API.
|
- **connect\_address**: IP address (or hostname) and port, to access the Patroni's 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 addres (ie: "localhost" or "127.0.0.1"). It can serve as a 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 and port that Patroni will listen to, to provide health-check information for HAProxy.
|
|
||||||
|
- **listen**: IP address (or hostname) and port that Patroni will listen to for the REST API - to provide also the same health checks and cluster messaging between the participating nodes, as described above. to provide health-check information for HAProxy (or any other load balancer capable of doing a HTTP "OPTION" or "GET" checks).
|
||||||
|
|
||||||
- **Optional**:
|
- **Optional**:
|
||||||
- **authentication**:
|
- **authentication**:
|
||||||
- **username**: Basic-auth username to protect unsafe REST API endpoints.
|
- **username**: Basic-auth username to protect unsafe REST API endpoints.
|
||||||
@@ -145,12 +163,21 @@ REST API
|
|||||||
- **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.
|
- **certfile**: Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
|
||||||
- **keyfile**: Specifies the file with the secret key in the PEM format.
|
- **keyfile**: Specifies the file with the secret key in the PEM format.
|
||||||
|
|
||||||
|
.. _patronictl_settings:
|
||||||
|
|
||||||
|
CTL
|
||||||
|
---
|
||||||
|
- **Optional**:
|
||||||
|
- **insecure**: Allow connections to REST API without verifying SSL certs.
|
||||||
|
- **cacert**: Specifices the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs.
|
||||||
|
- **certfile**: Specifies the file with the certificate in the PEM format to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "certfile" parameter.
|
||||||
|
|
||||||
ZooKeeper
|
ZooKeeper
|
||||||
----------
|
----------
|
||||||
- **hosts**: list of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...'].
|
- **hosts**: list of ZooKeeper cluster members in format: ['host1:port1', 'host2:port2', 'etc...'].
|
||||||
|
|
||||||
Watchdog
|
Watchdog
|
||||||
--------
|
--------
|
||||||
- **mode**: ``off``, ``automatic`` or ``required``. When ``off`` watchdog is disabled. When ``automatic`` watchdog will be used if available, but ignored if it is not. When ``required`` the node will not become a leader unless watchdog can be succesfully enabled.
|
- **mode**: ``off``, ``automatic`` or ``required``. When ``off`` watchdog is disabled. When ``automatic`` watchdog will be used if available, but ignored if it is not. When ``required`` the node will not become a leader unless watchdog can be successfully enabled.
|
||||||
- **device**: Path to watchdog device. Defaults to ``/dev/watchdog``.
|
- **device**: Path to watchdog device. Defaults to ``/dev/watchdog``.
|
||||||
- **safety_margin**: Number of seconds of safety margin between watchdog triggering and leader key expiration.
|
- **safety_margin**: Number of seconds of safety margin between watchdog triggering and leader key expiration.
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ Patroni configuration is stored in the DCS (Distributed Configuration Store). Th
|
|||||||
These options are defined in the configuration file and take precedence over dynamic configuration.
|
These options are defined in the configuration file and take precedence over dynamic configuration.
|
||||||
patroni.yml could be changed and reload in runtime (without restart of Patroni) by sending SIGHUP to the Patroni process or by performing ``POST /reload`` REST-API request.
|
patroni.yml could be changed and reload in runtime (without restart of Patroni) by sending SIGHUP to the Patroni process or by performing ``POST /reload`` REST-API request.
|
||||||
|
|
||||||
- Environment :ref:`configuration <environment>` .
|
- Environment :ref:`configuration <environment>`.
|
||||||
It is possible to set/override some of the "Local" configuration parameters with environment variables.
|
It is possible to set/override some of the "Local" configuration parameters with environment variables.
|
||||||
Environment configuration is very useful when you are running in a dynamic environment and you don't know some of the parameters in advance (for example it's not possible to know you external IP address when you are running inside ``docker``).
|
Environment configuration is very useful when you are running in a dynamic environment and you don't know some of the parameters in advance (for example it's not possible to know your external IP address when you are running inside ``docker``).
|
||||||
|
|
||||||
Some of the PostgreSQL parameters must hold the same values on the master and the replicas. For those, values set either in the local patroni configuration files or via the environment variables take no effect. To alter or set their values one must change the shared configuration in the DCS. Below is the actual list of such parameters together with the default values:
|
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:
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ There are some other Postgres parameters controlled by Patroni:
|
|||||||
|
|
||||||
- listen_addresses - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
|
- listen_addresses - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
|
||||||
- port - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
|
- port - is set either from ``postgresql.listen`` or from ``PATRONI_POSTGRESQL_LISTEN`` environment variable
|
||||||
- cluster_name - is set either from ``scope`` or from ``PATRRONI_SCOPE`` environment variable
|
- cluster_name - is set either from ``scope`` or from ``PATRONI_SCOPE`` environment variable
|
||||||
- hot_standby: on
|
- hot_standby: on
|
||||||
|
|
||||||
To be on the safe side parameters from the above lists are not written into ``postgresql.conf``, but passed as a list of arguments to the ``pg_ctl start`` which gives them the highest precedence, even above `ALTER SYSTEM <https://www.postgresql.org/docs/current/static/sql-altersystem.html>`__
|
To be on the safe side parameters from the above lists are not written into ``postgresql.conf``, but passed as a list of arguments to the ``pg_ctl start`` which gives them the highest precedence, even above `ALTER SYSTEM <https://www.postgresql.org/docs/current/static/sql-altersystem.html>`__
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ Examples
|
|||||||
- You can find the full-featured Docker image that can use Persistent Volumes in the
|
- You can find the full-featured Docker image that can use Persistent Volumes in the
|
||||||
`Spilo Project <https://github.com/zalando/spilo>`_.
|
`Spilo Project <https://github.com/zalando/spilo>`_.
|
||||||
|
|
||||||
- There is also a `Helm chart <https://github.com/unguiculus/charts/tree/feature/patroni/incubator/patroni>`_
|
- There is also a `Helm chart <https://github.com/kubernetes/charts/tree/master/incubator/patroni>`_
|
||||||
to deploy the Spilo image configured with Patroni running using Kubernetes.
|
to deploy the Spilo image configured with Patroni running using Kubernetes.
|
||||||
|
|
||||||
- In order to run your database clusters at scale using Patroni and Spilo, take a look at the
|
- In order to run your database clusters at scale using Patroni and Spilo, take a look at the
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ When Patroni runs in a paused mode, it does not change the state of PostgreSQL,
|
|||||||
|
|
||||||
- If there is no leader lock in the cluster, the running master acquires the lock. If there is more than one master node, then the first master to acquire the lock wins. If there are no masters altogether, Patroni does not try to promote any replicas. There is an exception in this rule: if there is no leader lock because the old master has demoted itself due to the manual promotion, then only the candidate node mentioned in the promotion request may take the leader lock. When the new leader lock is granted (i.e. after promoting a replica manually), Patroni makes sure the replicas that were streaming from the previous leader will switch to the new one.
|
- If there is no leader lock in the cluster, the running master acquires the lock. If there is more than one master node, then the first master to acquire the lock wins. If there are no masters altogether, Patroni does not try to promote any replicas. There is an exception in this rule: if there is no leader lock because the old master has demoted itself due to the manual promotion, then only the candidate node mentioned in the promotion request may take the leader lock. When the new leader lock is granted (i.e. after promoting a replica manually), Patroni makes sure the replicas that were streaming from the previous leader will switch to the new one.
|
||||||
|
|
||||||
- When Postgres is stopped, Patroni does not try to start it. When Patroni is stopped, it does not to stop Postgres instance it is managing.
|
- When Postgres is stopped, Patroni does not try to start it. When Patroni is stopped, it does not try to stop the Postgres instance it is managing.
|
||||||
|
|
||||||
User guide
|
User guide
|
||||||
----------
|
----------
|
||||||
|
|||||||
+319
-7
@@ -3,6 +3,318 @@
|
|||||||
Release notes
|
Release notes
|
||||||
=============
|
=============
|
||||||
|
|
||||||
|
Version 1.5.2
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Compatibility and bugfix release.
|
||||||
|
|
||||||
|
- Compatibility with kazoo-2.6.0 (Alexander Kukushkin)
|
||||||
|
|
||||||
|
In order to make sure that requests are performed with an appropriate timeout, Patroni redefines create_connection method from python-kazoo module. The last release of kazoo slightly changed the way how create_connection method is called.
|
||||||
|
|
||||||
|
- Fix Patroni crash when Consul cluster loses the leader (Alexander)
|
||||||
|
|
||||||
|
The crash was happening due to incorrect implementation of touch_member method, it should return boolean and not raise any exceptions.
|
||||||
|
|
||||||
|
Version 1.5.1
|
||||||
|
-------------
|
||||||
|
|
||||||
|
This version implements support of permanent replication slots, adds support of pgBackRest and fixes number of bugs.
|
||||||
|
|
||||||
|
**New features**
|
||||||
|
|
||||||
|
- Permanent replication slots (Alexander Kukushkin)
|
||||||
|
|
||||||
|
Permanent replication slots are preserved on failover/switchover, that is, Patroni on the new primary will create configured replication slots right after doing promote. Slots could be configured with the help of `patronictl edit-config`. The initial configuration could be also done in the :ref:`bootstrap.dcs <settings>`.
|
||||||
|
|
||||||
|
- Add pgbackrest support (Yogesh Sharma)
|
||||||
|
|
||||||
|
pgBackrest can restore in existing $PGDATA folder, this allows speedy restore as files which have not changed since last backup are skipped, to support this feature new parameter `keep_data` has been introduced. See :ref:`replica creation method <custom_replica_creation>` section for additional examples.
|
||||||
|
|
||||||
|
**Bug fixes**
|
||||||
|
|
||||||
|
- A few bugfixes in the "standby cluster" workflow (Alexander)
|
||||||
|
|
||||||
|
Please see https://github.com/zalando/patroni/pull/823 for more details.
|
||||||
|
|
||||||
|
- Fix REST API health check when cluster management is paused and DCS is not accessible (Alexander)
|
||||||
|
|
||||||
|
Regression was introduced in https://github.com/zalando/patroni/commit/90cf930036a9d5249265af15d2b787ec7517cf57
|
||||||
|
|
||||||
|
Version 1.5.0
|
||||||
|
-------------
|
||||||
|
|
||||||
|
This version enables Patroni HA cluster to operate in a standby mode, introduces experimental support for running on Windows, and provides a new configuration parameter to register PostgreSQL service in Consul.
|
||||||
|
|
||||||
|
**New features**
|
||||||
|
|
||||||
|
- Standby cluster (Dmitry Dolgov)
|
||||||
|
|
||||||
|
One or more Patroni nodes can form a standby cluster that runs alongside the primary one (i.e. in another datacenter) and consists of standby nodes that replicate from the master in the primary cluster. All PostgreSQL nodes in the standby cluster are replicas; one of those replicas elects itself to replicate directly from the remote master, while the others replicate from it in a cascading manner. More detailed description of this feature and some configuration examples can be found at :ref:`here <standby_cluster>`.
|
||||||
|
|
||||||
|
- Register Services in Consul (Pavel Kirillov, Alexander Kukushkin)
|
||||||
|
|
||||||
|
If `register_service` parameter in the consul :ref:`configuration <consul_settings>` is enabled, the node will register a service with the name `scope` and the tag `master`, `replica` or `standby-leader`.
|
||||||
|
|
||||||
|
- Experimental Windows support (Pavel Golub)
|
||||||
|
|
||||||
|
From now on it is possible to run Patroni on Windows, although Windows support is brand-new and hasn't received as much real-world testing as its Linux counterpart. We welcome your feedback!
|
||||||
|
|
||||||
|
**Improvements in patronictl**
|
||||||
|
|
||||||
|
- Add patronictl -k/--insecure flag and support for restapi cert (Wilfried Roset)
|
||||||
|
|
||||||
|
In the past if the REST API was protected by the self-signed certificates `patronictl` would fail to verify them. There was no way to disable that verification. It is now possible to configure `patronictl` to skip the certificate verification altogether or provide CA and client certificates in the :ref:`ctl: <patronictl_settings>` section of configuration.
|
||||||
|
|
||||||
|
- Exclude members with nofailover tag from patronictl switchover/failover output (Alexander Anikin)
|
||||||
|
|
||||||
|
Previously, those members were incorrectly proposed as candidates when performing interactive switchover or failover via patronictl.
|
||||||
|
|
||||||
|
**Stability improvements**
|
||||||
|
|
||||||
|
- Avoid parsing non-key-value output lines of pg_controldata (Alexander Anikin)
|
||||||
|
|
||||||
|
Under certain circuimstances pg_controldata outputs lines without a colon character. That would trigger an error in Patroni code that parsed pg_controldata output, hiding the actual problem; often such lines are emitted in a warning shown by pg_controldata before the regular output, i.e. when the binary major version does not match the one of the PostgreSQL data directory.
|
||||||
|
|
||||||
|
- Add member name to the error message during the leader election (Jan Mussler)
|
||||||
|
|
||||||
|
During the leader election, Patroni connects to all known members of the cluster and requests their status. Such status is written to the Patroni log and includes the name of the member. Previously, if the member was not accessible, the error message did not indicate its name, containing only the URL.
|
||||||
|
|
||||||
|
- Immediately reserve the WAL position upon creation of the replication slot (Alexander Kukushkin)
|
||||||
|
|
||||||
|
Starting from 9.6, `pg_create_physical_replication_slot` function provides an additional boolean parameter `immediately_reserve`. When it is set to `false`, which is also the default, the slot doesn't reserve the WAL position until it receives the first client connection, potentially losing some segments required by the client in a time window between the slot creation and the intiial client connection.
|
||||||
|
|
||||||
|
- Fix bug in strict synchronous replication (Alexander Kukushkin)
|
||||||
|
|
||||||
|
When running with `synchronous_mode_strict: true`, in some cases Patroni puts `*` into the `synchronous_standby_names`, changing the sync state for most of the replication connections to `potential`. Previously, Patroni couldn't pick a synchronous candidate under such curcuimstances, as it only considered those with the state `async`.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.4.6
|
||||||
|
-------------
|
||||||
|
|
||||||
|
**Bug fixes and stability improvements**
|
||||||
|
|
||||||
|
This release fixes a critical issue with Patroni API /master endpoint returning 200 for the non-master node. This is a
|
||||||
|
reporting issue, no actual split-brain, but under certain circumstances clients might be directed to the read-only node.
|
||||||
|
|
||||||
|
- Reset is_leader status on demote (Alexander Kukushkin, Oleksii Kliukin)
|
||||||
|
|
||||||
|
Make sure demoted cluster member stops responding with code 200 on the /master API call.
|
||||||
|
|
||||||
|
- Add new "cluster_unlocked" field to the API output (Dmitry Dolgov)
|
||||||
|
|
||||||
|
This field indicates whether the cluster has the master running. It can be used when it is not possible to query any
|
||||||
|
other node but one of the replicas.
|
||||||
|
|
||||||
|
Version 1.4.5
|
||||||
|
-------------
|
||||||
|
|
||||||
|
**New features**
|
||||||
|
|
||||||
|
- Improve logging when applying new postgres configuration (Don Seiler)
|
||||||
|
|
||||||
|
Patroni logs changed parameter names and values.
|
||||||
|
|
||||||
|
- Python 3.7 compatibility (Christoph Berg)
|
||||||
|
|
||||||
|
async is a reserved keyword in python3.7
|
||||||
|
|
||||||
|
- Set state to "stopped" in the DCS when a member is shut down (Tony Sorrentino)
|
||||||
|
|
||||||
|
This shows the member state as "stopped" in "patronictl list" command.
|
||||||
|
|
||||||
|
- Improve the message logged when stale postmaster.pid matches a running process (Ants Aasma)
|
||||||
|
|
||||||
|
The previous one was beyond confusing.
|
||||||
|
|
||||||
|
- Implement patronictl reload functionality (Don Seiler)
|
||||||
|
|
||||||
|
Before that it was only possible to reload configuration by either calling REST API or by sending SIGHUP signal to the Patroni process.
|
||||||
|
|
||||||
|
- Take and apply some parameters from controldata when starting as a replica (Alexander Kukushkin)
|
||||||
|
|
||||||
|
The value of `max_connections` and some other parameters set in the global configuration may be lower than the one actually used by the primary; when this happens, the replica cannot start and should be fixed manually. Patroni takes care of that now by reading and applying the value from `pg_controldata`, starting postgres and setting `pending_restart` flag.
|
||||||
|
|
||||||
|
- If set, use LD_LIBRARY_PATH when starting postgres (Chris Fraser)
|
||||||
|
|
||||||
|
When starting up Postgres, Patroni was passing along PATH, LC_ALL and LANG env vars if they are set. Now it is doing the same with LD_LIBRARY_PATH. It should help if somebody installed PostgreSQL to non-standard place.
|
||||||
|
|
||||||
|
- Rename create_replica_method to create_replica_methods (Dmitry Dolgov)
|
||||||
|
|
||||||
|
To make it clear that it's actually an array. The old name is still supported for backward compatibility.
|
||||||
|
|
||||||
|
**Bug fixes and stability improvements**
|
||||||
|
|
||||||
|
- Fix condition for the replica start due to pg_rewind in paused state (Oleksii Kliukin)
|
||||||
|
|
||||||
|
Avoid starting the replica that had already executed pg_rewind before.
|
||||||
|
|
||||||
|
- Respond 200 to the master health-check only if update_lock has been successful (Alexander)
|
||||||
|
|
||||||
|
Prevent Patroni from reporting itself a master on the former (demoted) master if DCS is partitioned.
|
||||||
|
|
||||||
|
- Fix compatibility with the new consul module (Alexander)
|
||||||
|
|
||||||
|
Starting from v1.1.0 python-consul changed internal API and started using `list` instead of `dict` to pass query parameters.
|
||||||
|
|
||||||
|
- Catch exceptions from Patroni REST API thread during shutdown (Alexander)
|
||||||
|
|
||||||
|
Those uncaught exceptions kept PostgreSQL running at shutdown.
|
||||||
|
|
||||||
|
- Do crash recovery only when Postgres runs as the master (Alexander)
|
||||||
|
|
||||||
|
Require `pg_controldata` to report 'in production' or 'shutting down' or 'in crash recovery'. In all other cases no crash recovery is necessary.
|
||||||
|
|
||||||
|
- Improve handling of configuration errors (Henning Jacobs, Alexander)
|
||||||
|
|
||||||
|
It is possible to change a lot of parameters in runtime (including `restapi.listen`) by updating Patroni config file and sending SIGHUP to Patroni process. This fix eliminates obscure exceptions from the 'restapi' thread when some of the parameters receive invalid values.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.4.4
|
||||||
|
-------------
|
||||||
|
|
||||||
|
**Stability improvements**
|
||||||
|
|
||||||
|
- Fix race condition in poll_failover_result (Alexander Kukushkin)
|
||||||
|
|
||||||
|
It didn't affect directly neither failover nor switchover, but in some rare cases it was reporting success too early, when the former leader released the lock, producing a 'Failed over to "None"' instead of 'Failed over to "desired-node"' message.
|
||||||
|
|
||||||
|
- Treat Postgres parameter names as case insensitive (Alexander)
|
||||||
|
|
||||||
|
Most of the Postgres parameters have snake_case names, but there are three exceptions from this rule: DateStyle, IntervalStyle and TimeZone. Postgres accepts those parameters when written in a different case (e.g. timezone = 'some/tzn'); however, Patroni was unable to find case-insensitive matches of those parameter names in pg_settings and ignored such parameters as a result.
|
||||||
|
|
||||||
|
- Abort start if attaching to running postgres and cluster not initialized (Alexander)
|
||||||
|
|
||||||
|
Patroni can attach itself to an already running Postgres instance. It is imperative to start running Patroni on the master node before getting to the replicas.
|
||||||
|
|
||||||
|
- Fix behavior of patronictl scaffold (Alexander)
|
||||||
|
|
||||||
|
Pass dict object to touch_member instead of json encoded string, DCS implementation will take care of encoding it.
|
||||||
|
|
||||||
|
- Don't demote master if failed to update leader key in pause (Alexander)
|
||||||
|
|
||||||
|
During maintenance a DCS may start failing write requests while continuing to responds to read ones. In that case, Patroni used to put the Postgres master node to a read-only mode after failing to update the leader lock in DCS.
|
||||||
|
|
||||||
|
- Sync replication slots when Patroni notices a new postmaster process (Alexander)
|
||||||
|
|
||||||
|
If Postgres has been restarted, Patroni has to make sure that list of replication slots matches its expectations.
|
||||||
|
|
||||||
|
- Verify sysid and sync replication slots after coming out of pause (Alexander)
|
||||||
|
|
||||||
|
During the `maintenance` mode it may happen that data directory was completely rewritten and therefore we have to make sure that `Database system identifier` still belongs to our cluster and replication slots are in sync with Patroni expectations.
|
||||||
|
|
||||||
|
- Fix a possible failure to start not running Postgres on a data directory with postmaster lock file present (Alexander)
|
||||||
|
|
||||||
|
Detect reuse of PID from the postmaster lock file. More likely to hit such problem if you run Patroni and Postgres in the docker container.
|
||||||
|
|
||||||
|
- Improve protection of DCS being accidentally wiped (Alexander)
|
||||||
|
|
||||||
|
Patroni has a lot of logic in place to prevent failover in such case; it can also restore all keys back; however, until this change an accidental removal of /config key was switching off pause mode for 1 cycle of HA loop.
|
||||||
|
|
||||||
|
- Do not exit when encountering invalid system ID (Oleksii Kliukin)
|
||||||
|
|
||||||
|
Do not exit when the cluster system ID is empty or the one that doesn't pass the validation check. In that case, the cluster most likely needs a reinit; mention it in the result message. Avoid terminating Patroni, as otherwise reinit cannot happen.
|
||||||
|
|
||||||
|
**Compatibility with Kubernetes 1.10+**
|
||||||
|
|
||||||
|
- Added check for empty subsets (Cody Coons)
|
||||||
|
|
||||||
|
Kubernetes 1.10.0+ started returning `Endpoints.subsets` set to `None` instead of `[]`.
|
||||||
|
|
||||||
|
**Bootstrap improvements**
|
||||||
|
|
||||||
|
- Make deleting recovery.conf optional (Brad Nicholson)
|
||||||
|
|
||||||
|
If `bootstrap.<custom_bootstrap_method_name>.keep_existing_recovery_conf` is defined and set to ``True``, Patroni will not remove the existing ``recovery.conf`` file. This is useful when bootstrapping from a backup with tools like pgBackRest that generate the appropriate `recovery.conf` for you.
|
||||||
|
|
||||||
|
- Allow options to the basebackup built-in method (Oleksii)
|
||||||
|
|
||||||
|
It is now possible to supply options to the built-in basebackup method by defining the `basebackup` section in the configuration, similar to how those are defined for custom replica creation methods. The difference is in the format accepted by the `basebackup` section: since pg_basebackup accepts both `--key=value` and `--key` options, the contents of the section could be either a dictionary of key-value pairs, or a list of either one-element dictionaries or just keys (for the options that don't accept values). See :ref:`replica creation method <custom_replica_creation>` section for additional examples.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.4.3
|
||||||
|
-------------
|
||||||
|
|
||||||
|
**Improvements in logging**
|
||||||
|
|
||||||
|
- Make log level configurable from environment variables (Andy Newton, Keyvan Hedayati)
|
||||||
|
|
||||||
|
`PATRONI_LOGLEVEL` - sets the general logging level
|
||||||
|
`PATRONI_REQUESTS_LOGLEVEL` - sets the logging level for all HTTP requests e.g. Kubernetes API calls
|
||||||
|
See `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>` to get the names of possible log levels
|
||||||
|
|
||||||
|
**Stability improvements and bug fixes**
|
||||||
|
|
||||||
|
- Don't rediscover etcd cluster topology when watch timed out (Alexander Kukushkin)
|
||||||
|
|
||||||
|
If we have only one host in etcd configuration and exactly this host is not accessible, Patroni was starting discovery of cluster topology and never succeeding. Instead it should just switch to the next available node.
|
||||||
|
|
||||||
|
- Write content of bootstrap.pg_hba into a pg_hba.conf after custom bootstrap (Alexander)
|
||||||
|
|
||||||
|
Now it behaves similarly to the usual bootstrap with `initdb`
|
||||||
|
|
||||||
|
- Single user mode was waiting for user input and never finish (Alexander)
|
||||||
|
|
||||||
|
Regression was introduced in https://github.com/zalando/patroni/pull/576
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.4.2
|
||||||
|
-------------
|
||||||
|
|
||||||
|
**Improvements in patronictl**
|
||||||
|
|
||||||
|
- Rename scheduled failover to scheduled switchover (Alexander Kukushkin)
|
||||||
|
|
||||||
|
Failover and switchover functions were separated in version 1.4, but `patronictl list` was still reporting `Scheduled failover` instead of `Scheduled switchover`.
|
||||||
|
|
||||||
|
- Show information about pending restarts (Alexander)
|
||||||
|
|
||||||
|
In order to apply some configuration changes sometimes it is necessary to restart postgres. Patroni was already giving a hint about that in the REST API and when writing node status into DCS, but there were no easy way to display it.
|
||||||
|
|
||||||
|
- Make show-config to work with cluster_name from config file (Alexander)
|
||||||
|
|
||||||
|
It works similar to the `patronictl edit-config`
|
||||||
|
|
||||||
|
**Stability improvements**
|
||||||
|
|
||||||
|
- Avoid calling pg_controldata during bootstrap (Alexander)
|
||||||
|
|
||||||
|
During initdb or custom bootstrap there is a time window when pgdata is not empty but pg_controldata has not been written yet. In such case pg_controldata call was failing with error messages.
|
||||||
|
|
||||||
|
- Handle exceptions raised from psutil (Alexander)
|
||||||
|
|
||||||
|
cmdline is read and parsed every time when `cmdline()` method is called. It could happen that the process being examined
|
||||||
|
has already disappeared, in that case `NoSuchProcess` is raised.
|
||||||
|
|
||||||
|
**Kubernetes support improvements**
|
||||||
|
|
||||||
|
- Don't swallow errors from k8s API (Alexander)
|
||||||
|
|
||||||
|
A call to Kubernetes API could fail for a different number of reasons. In some cases such call should be retried, in some other cases we should log the error message and the exception stack trace. The change here will help debug Kubernetes permission issues.
|
||||||
|
|
||||||
|
- Update Kubernetes example Dockerfile to install Patroni from the master branch (Maciej Szulik)
|
||||||
|
|
||||||
|
Before that it was using `feature/k8s`, which became outdated.
|
||||||
|
|
||||||
|
- Add proper RBAC to run patroni on k8s (Maciej)
|
||||||
|
|
||||||
|
Add the Service account that is assigned to the pods of the cluster, the role that holds only the necessary permissions, and the rolebinding that connects the Service account and the Role.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.4.1
|
||||||
|
-------------
|
||||||
|
|
||||||
|
**Fixes in patronictl**
|
||||||
|
|
||||||
|
- Don't show current leader in suggested list of members to failover to. (Alexander Kukushkin)
|
||||||
|
|
||||||
|
patronictl failover could still work when there is leader in the cluster and it should be excluded from the list of member where it is possible to failover to.
|
||||||
|
|
||||||
|
- Make patronictl switchover compatible with the old Patroni api (Alexander)
|
||||||
|
|
||||||
|
In case if POST /switchover REST API call has failed with status code 501 it will do it once again, but to /failover endpoint.
|
||||||
|
|
||||||
|
|
||||||
Version 1.4
|
Version 1.4
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
@@ -25,8 +337,8 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
|
|||||||
|
|
||||||
- Minimize the amount of SELECT's issued by Patroni on every loop of HA cylce (Alexander Kukushkin)
|
- Minimize the amount of SELECT's issued by Patroni on every loop of HA cylce (Alexander Kukushkin)
|
||||||
|
|
||||||
On every iteration of HA loop Patroni needs to know recovery status and absolute wal position. From now on Patroni will run only single SELECT to get this information instead of two on the replica and three on the master.
|
On every iteration of HA loop Patroni needs to know recovery status and absolute wal position. From now on Patroni will run only single SELECT to get this information instead of two on the replica and three on the master.
|
||||||
|
|
||||||
- Remove leader key on shutdown only when we have the lock (Ants)
|
- Remove leader key on shutdown only when we have the lock (Ants)
|
||||||
|
|
||||||
Unconditional removal was generating unnecessary and missleading exceptions.
|
Unconditional removal was generating unnecessary and missleading exceptions.
|
||||||
@@ -36,7 +348,7 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
|
|||||||
- Add version command to patronictl (Ants)
|
- Add version command to patronictl (Ants)
|
||||||
|
|
||||||
It will show the version of installed Patroni and versions of running Patroni instances (if the cluster name is specified).
|
It will show the version of installed Patroni and versions of running Patroni instances (if the cluster name is specified).
|
||||||
|
|
||||||
- Make optional specifying cluster_name argument for some of patronictl commands (Alexander, Ants)
|
- Make optional specifying cluster_name argument for some of patronictl commands (Alexander, Ants)
|
||||||
|
|
||||||
It will work if patronictl is using usual Patroni configuration file with the ``scope`` defined.
|
It will work if patronictl is using usual Patroni configuration file with the ``scope`` defined.
|
||||||
@@ -44,11 +356,11 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
|
|||||||
- Show information about scheduled switchover and maintenance mode (Alexander)
|
- Show information about scheduled switchover and maintenance mode (Alexander)
|
||||||
|
|
||||||
Before that it was possible to get this information only from Patroni logs or directly from DCS.
|
Before that it was possible to get this information only from Patroni logs or directly from DCS.
|
||||||
|
|
||||||
- Improve ``patronictl reinit`` (Alexander)
|
- Improve ``patronictl reinit`` (Alexander)
|
||||||
|
|
||||||
Sometimes ``patronictl reinit`` refused to proceed when Patroni was busy with other actions, namely trying to start postgres. `patronictl` didn't provide any commands to cancel such long running actions and the only (dangerous) workarond was removing a data directory manually. The new implementation of `reinit` forcefully cancells other long-running actions before proceeding with reinit.
|
Sometimes ``patronictl reinit`` refused to proceed when Patroni was busy with other actions, namely trying to start postgres. `patronictl` didn't provide any commands to cancel such long running actions and the only (dangerous) workarond was removing a data directory manually. The new implementation of `reinit` forcefully cancells other long-running actions before proceeding with reinit.
|
||||||
|
|
||||||
- Implement ``--wait`` flag in ``patronictl pause`` and ``patronictl resume`` (Alexander)
|
- Implement ``--wait`` flag in ``patronictl pause`` and ``patronictl resume`` (Alexander)
|
||||||
|
|
||||||
It will make ``patronictl`` wait until the requested action is acknowledged by all nodes in the cluster.
|
It will make ``patronictl`` wait until the requested action is acknowledged by all nodes in the cluster.
|
||||||
@@ -95,7 +407,7 @@ Version 1.3.6
|
|||||||
After a crash that doesn't clean up postmaster.pid there could be a new process with the same pid, resulting in a false positive for is_running(), which will lead to all kinds of bad behavior.
|
After a crash that doesn't clean up postmaster.pid there could be a new process with the same pid, resulting in a false positive for is_running(), which will lead to all kinds of bad behavior.
|
||||||
|
|
||||||
- Shutdown postgresql before bootstrap when we lost data directory (ainlolcat)
|
- Shutdown postgresql before bootstrap when we lost data directory (ainlolcat)
|
||||||
|
|
||||||
When data directory on the master is forcefully removed, postgres process can still stay alive for some time and prevent the replica created in place of that former master from starting or replicating.
|
When data directory on the master is forcefully removed, postgres process can still stay alive for some time and prevent the replica created in place of that former master from starting or replicating.
|
||||||
The fix makes Patroni cache the postmaster pid and its start time and let it terminate the old postmaster in case it is still running after the corresponding data directory has been removed.
|
The fix makes Patroni cache the postmaster pid and its start time and let it terminate the old postmaster in case it is still running after the corresponding data directory has been removed.
|
||||||
|
|
||||||
@@ -165,7 +477,7 @@ Version 1.3.4
|
|||||||
- Pass the consul token as a header (Andrew Colin Kissa)
|
- Pass the consul token as a header (Andrew Colin Kissa)
|
||||||
|
|
||||||
Headers are now the prefered way to pass the token to the consul `API <https://www.consul.io/api/index.html#authentication>`__.
|
Headers are now the prefered way to pass the token to the consul `API <https://www.consul.io/api/index.html#authentication>`__.
|
||||||
|
|
||||||
|
|
||||||
- Advanced configuration for Consul (Alexander Kukushkin)
|
- Advanced configuration for Consul (Alexander Kukushkin)
|
||||||
|
|
||||||
|
|||||||
+107
-7
@@ -3,7 +3,7 @@ Replica imaging and bootstrap
|
|||||||
|
|
||||||
Patroni allows customizing creation of a new replica. It also supports defining what happens when the new empty cluster
|
Patroni allows customizing creation of a new replica. It also supports defining what happens when the new empty cluster
|
||||||
is being bootstrapped. The distinction between two is well defined: Patroni creates replicas only if the ``initialize``
|
is being bootstrapped. The distinction between two is well defined: Patroni creates replicas only if the ``initialize``
|
||||||
key is present in Etcd for the cluster. If there is no ``initialize`` key - Patroni calls bootstrap exclusively on the
|
key is present in DCS for the cluster. If there is no ``initialize`` key - Patroni calls bootstrap exclusively on the
|
||||||
first node that takes the initialize key lock.
|
first node that takes the initialize key lock.
|
||||||
|
|
||||||
.. _custom_bootstrap:
|
.. _custom_bootstrap:
|
||||||
@@ -23,6 +23,7 @@ arguments to them, i.e. the name of the cluster and the path to the data directo
|
|||||||
method: <custom_bootstrap_method_name>
|
method: <custom_bootstrap_method_name>
|
||||||
<custom_bootstrap_method_name>:
|
<custom_bootstrap_method_name>:
|
||||||
command: <path_to_custom_bootstrap_script> [param1 [, ...]]
|
command: <path_to_custom_bootstrap_script> [param1 [, ...]]
|
||||||
|
keep_existing_recovery_conf: False
|
||||||
recovery_conf:
|
recovery_conf:
|
||||||
recovery_target_action: promote
|
recovery_target_action: promote
|
||||||
recovery_target_timeline: latest
|
recovery_target_timeline: latest
|
||||||
@@ -47,6 +48,9 @@ If a ``recovery_conf`` block is defined in the same section as the custom bootst
|
|||||||
``recovery.conf`` before starting the newly bootstrapped instance. Typically, such recovery.conf should contain at least
|
``recovery.conf`` before starting the newly bootstrapped instance. Typically, such recovery.conf should contain at least
|
||||||
one of the ``recovery_target_*`` parameters, together with the ``recovery_target_timeline`` set to ``promote``.
|
one of the ``recovery_target_*`` parameters, together with the ``recovery_target_timeline`` set to ``promote``.
|
||||||
|
|
||||||
|
If ``keep_existing_recovery_conf`` is defined and set to ``True``, Patroni will not remove the existing ``recovery.conf`` file if it exists.
|
||||||
|
This is useful when bootstrapping from a backup with tools like pgBackRest that generate the appropriate ``recovery.conf`` for you.
|
||||||
|
|
||||||
.. note:: Bootstrap methods are neither chained, nor fallen-back to the default one in case the primary one fails
|
.. note:: Bootstrap methods are neither chained, nor fallen-back to the default one in case the primary one fails
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +68,20 @@ scripts to clone a new replica. Those are configured in the ``postgresql`` confi
|
|||||||
.. code:: YAML
|
.. code:: YAML
|
||||||
|
|
||||||
postgresql:
|
postgresql:
|
||||||
create_replica_method:
|
create_replica_methods:
|
||||||
|
- <method name>
|
||||||
|
<method name>:
|
||||||
|
command: <command name>
|
||||||
|
keep_data: True
|
||||||
|
no_params: True
|
||||||
|
no_master: 1
|
||||||
|
|
||||||
|
example: wal_e
|
||||||
|
|
||||||
|
.. code:: YAML
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
create_replica_methods:
|
||||||
- wal_e
|
- wal_e
|
||||||
- basebackup
|
- basebackup
|
||||||
wal_e:
|
wal_e:
|
||||||
@@ -72,13 +89,29 @@ scripts to clone a new replica. Those are configured in the ``postgresql`` confi
|
|||||||
no_master: 1
|
no_master: 1
|
||||||
envdir: {{WALE_ENV_DIR}}
|
envdir: {{WALE_ENV_DIR}}
|
||||||
use_iam: 1
|
use_iam: 1
|
||||||
|
basebackup:
|
||||||
|
max-rate: '100M'
|
||||||
|
|
||||||
|
example: pgbackrest
|
||||||
|
|
||||||
|
.. code:: YAML
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
create_replica_methods:
|
||||||
|
- pgbackrest
|
||||||
|
- basebackup
|
||||||
|
pgbackrest:
|
||||||
|
command: /usr/bin/pgbackrest --stanza=<scope> --delta restore
|
||||||
|
keep_data: True
|
||||||
|
no_params: True
|
||||||
|
basebackup:
|
||||||
|
max-rate: '100M'
|
||||||
|
|
||||||
|
|
||||||
The ``create_replica_method`` defines available replica creation methods and the order of executing them. Patroni will
|
The ``create_replica_methods`` defines available replica creation methods and the order of executing them. Patroni will
|
||||||
stop on the first one that returns 0. The basebackup is the built-in method and doesn't require any configuration. The
|
stop on the first one that returns 0. Each method should define a separate section in the configuration file, listing the command
|
||||||
rest of the methods should define a separate section in the configuration file, listing the command to execute and any
|
to execute and any custom parameters that should be passed to that command. All parameters will be passed in a
|
||||||
custom parameters that should be passed to that command. All parameters will be passed in a ``--name=value`` format.
|
``--name=value`` format. Besides user-defined parameters, Patroni supplies a couple of cluster-specific ones:
|
||||||
Besides user-defined parameters, Patroni supplies a couple of cluster-specific ones:
|
|
||||||
|
|
||||||
--scope
|
--scope
|
||||||
Which cluster this replica belongs to
|
Which cluster this replica belongs to
|
||||||
@@ -94,4 +127,71 @@ A special ``no_master`` parameter, if defined, allows Patroni to call the replic
|
|||||||
running master or replicas. In that case, an empty string will be passed in a connection string. This is useful for
|
running master or replicas. In that case, an empty string will be passed in a connection string. This is useful for
|
||||||
restoring the formerly running cluster from the binary backup.
|
restoring the formerly running cluster from the binary backup.
|
||||||
|
|
||||||
|
A special ``keep_data`` parameter, if defined, will instuct Patroni to not clean PGDATA folder before calling restore.
|
||||||
|
|
||||||
|
A special ``no_params`` parameter, if defined, restricts passing parameters to custom command.
|
||||||
|
|
||||||
|
A ``basebackup`` method is a special case: it will be used if
|
||||||
|
``create_replica_methods`` is empty, although it is possible
|
||||||
|
to list it explicitly among the ``create_replica_methods`` methods. This method initializes a new replica with the
|
||||||
|
``pg_basebackup``, the base backup is taken from the master unless there are replicas with ``clonefrom`` tag, in which case one
|
||||||
|
of such replicas will be used as the origin for pg_basebackup. It works without any configuration; however, it is
|
||||||
|
possible to specify a ``basebackup`` configuration section. Same rules as with the other method configuration apply,
|
||||||
|
namely, only long (with --) options should be specified there. Not all parameters make sense, if you override a connection
|
||||||
|
string or provide an option to created tar-ed or compressed base backups, patroni won't be able to make a replica out
|
||||||
|
of it. There is no validation performed on the names or values of the parameters passed to the ``basebackup`` section.
|
||||||
|
You can specify basebackup parameters as either a map (key-value pairs) or a list of elements, where each element
|
||||||
|
could be either a key-value pair or a single key (for options that does not receive any values, for instance, ``--verbose``).
|
||||||
|
Consider those 2 examples:
|
||||||
|
|
||||||
|
.. code:: YAML
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
basebackup:
|
||||||
|
max-rate: '100M'
|
||||||
|
checkpoint: 'fast'
|
||||||
|
|
||||||
|
and
|
||||||
|
|
||||||
|
.. code:: YAML
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
basebackup:
|
||||||
|
- verbose
|
||||||
|
- max-rate: '100M'
|
||||||
|
|
||||||
If all replica creation methods fail, Patroni will try again all methods in order during the next event loop cycle.
|
If all replica creation methods fail, Patroni will try again all methods in order during the next event loop cycle.
|
||||||
|
|
||||||
|
.. _standby_cluster:
|
||||||
|
|
||||||
|
Standby cluster
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Another available option is to run a "standby cluster", that contains only of
|
||||||
|
standby nodes replicating from some remote master. This type of clusters has:
|
||||||
|
|
||||||
|
* "standby leader", that behaves pretty much like a regular cluster leader,
|
||||||
|
except it replicates from a remote master.
|
||||||
|
|
||||||
|
* cascade replicas, that are replicating from standby leader.
|
||||||
|
|
||||||
|
Standby leader holds and updates a leader lock in DCS. If the leader lock
|
||||||
|
expires, cascade replicas will perform an election to choose another leader
|
||||||
|
from the standbys. For the sake of flexibility, you can specify different
|
||||||
|
methods of creating a replica and recovery WAL records when a cluster is in the
|
||||||
|
"standby mode", and after it was detached to function as a normal cluster.
|
||||||
|
|
||||||
|
To configure such cluster you need to specify the section ``standby_cluster``
|
||||||
|
in a patroni configuration:
|
||||||
|
|
||||||
|
.. code:: YAML
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
dcs:
|
||||||
|
standby_cluster:
|
||||||
|
host: 1.2.3.4
|
||||||
|
port: 5432
|
||||||
|
primary_slot_name: patroni
|
||||||
|
|
||||||
|
Note, that these options will be applied only once during cluster bootstrap,
|
||||||
|
and the only way to change them afterwards is through DCS.
|
||||||
|
|||||||
+19
-10
@@ -9,9 +9,9 @@ Patroni uses PostgreSQL streaming replication. For more information about stream
|
|||||||
Asynchronous mode durability
|
Asynchronous mode durability
|
||||||
----------------------------
|
----------------------------
|
||||||
|
|
||||||
In asynchronous mode the cluster is allowed to lose some committed transactions to ensure availability. When master server fails or becomes unavailable for any other reason Patroni will automatically promote a sufficiently healthy standby to master. Any transactions that have not been replicated to that standby remain in a "forked timeline" on the master, and are effectively unrecoverable [1]_.
|
In asynchronous mode the cluster is allowed to lose some committed transactions to ensure availability. When the primary server fails or becomes unavailable for any other reason Patroni will automatically promote a sufficiently healthy standby to primary. Any transactions that have not been replicated to that standby remain in a "forked timeline" on the primary, and are effectively unrecoverable [1]_.
|
||||||
|
|
||||||
The amount of transactions that can be lost is controlled via ``maximum_lag_on_failover`` parameter. Because master transaction log position is not sampled in real time, in reality the amount of lost data on failover is worst case bounded by ``maximum_lag_on_failover`` bytes of transaction log plus the amount that is written in the last ``ttl`` seconds (``loop_wait``/2 seconds in the average case). However typical steady state replication delay is well under a second.
|
The amount of transactions that can be lost is controlled via ``maximum_lag_on_failover`` parameter. Because the primary transaction log position is not sampled in real time, in reality the amount of lost data on failover is worst case bounded by ``maximum_lag_on_failover`` bytes of transaction log plus the amount that is written in the last ``ttl`` seconds (``loop_wait``/2 seconds in the average case). However typical steady state replication delay is well under a second.
|
||||||
|
|
||||||
PostgreSQL synchronous replication
|
PostgreSQL synchronous replication
|
||||||
----------------------------------
|
----------------------------------
|
||||||
@@ -20,7 +20,7 @@ You can use Postgres's `synchronous replication <http://www.postgresql.org/docs/
|
|||||||
|
|
||||||
In hosted datacenter environments (like AWS, Rackspace, or any network you do not control), synchronous replication significantly increases the variability of write performance. If followers become inaccessible from the leader, the leader effectively becomes read-only.
|
In hosted datacenter environments (like AWS, Rackspace, or any network you do not control), synchronous replication significantly increases the variability of write performance. If followers become inaccessible from the leader, the leader effectively becomes read-only.
|
||||||
|
|
||||||
To enable a simple synchronous replication test, add the follow lines to the ``parameters`` section of your YAML configuration files:
|
To enable a simple synchronous replication test, add the following lines to the ``parameters`` section of your YAML configuration files:
|
||||||
|
|
||||||
.. code:: YAML
|
.. code:: YAML
|
||||||
|
|
||||||
@@ -29,18 +29,27 @@ To enable a simple synchronous replication test, add the follow lines to the ``p
|
|||||||
|
|
||||||
When using PostgreSQL synchronous replication, use at least three Postgres data nodes to ensure write availability if one host fails.
|
When using PostgreSQL synchronous replication, use at least three Postgres data nodes to ensure write availability if one host fails.
|
||||||
|
|
||||||
Using PostgreSQL synchronous replication does not guarantee zero lost transactions under all circumstances. When master and standby that is currently acting as synchronous fail simultaneously a third node that might not contain all transactions will be promoted.
|
Using PostgreSQL synchronous replication does not guarantee zero lost transactions under all circumstances. When the primary and the secondary that is currently acting as a synchronous replica fail simultaneously a third node that might not contain all transactions will be promoted.
|
||||||
|
|
||||||
.. _synchronous_mode:
|
.. _synchronous_mode:
|
||||||
|
|
||||||
Synchronous mode
|
Synchronous mode
|
||||||
----------------
|
----------------
|
||||||
|
|
||||||
For use cases where losing committed transactions is not permissible you can turn on Patronis ``synchronous_mode``. When ``synchronous_mode`` is turned on Patroni will not promote a standby unless it is certain that the standby contains all transactions that may have returned a successful commit status to client [2]_. This means that the system may be unavailable for writes even though some servers are available. System administrators can still use manual failover commmands to promote a standby even if it results in transaction loss.
|
For use cases where losing committed transactions is not permissible you can turn on Patroni's ``synchronous_mode``. When ``synchronous_mode`` is turned on Patroni will not promote a standby unless it is certain that the standby contains all transactions that may have returned a successful commit status to client [2]_. This means that the system may be unavailable for writes even though some servers are available. System administrators can still use manual failover commands to promote a standby even if it results in transaction loss.
|
||||||
|
|
||||||
Turning on ``synchronous_mode`` does not guarantee multi node durability of commits under all circumstances. When no suitable standby is available, master server will still accept writes, but does not guarantee their replication. When the master fails in this mode no standby will be promote. When the host that used to be master comes back it will get promoted automatically, unless system administrator performed a manual failover. This behavior makes synchronous mode usable with 2 node clusters.
|
Turning on ``synchronous_mode`` does not guarantee multi node durability of commits under all circumstances. When no suitable standby is available, primary server will still accept writes, but does not guarantee their replication. When the primary fails in this mode no standby will be promoted. When the host that used to be the primary comes back it will get promoted automatically, unless system administrator performed a manual failover. This behavior makes synchronous mode usable with 2 node clusters.
|
||||||
|
|
||||||
When ``synchronous_mode`` is on and a standby crashes, commits will block until next iteration of Patroni runs and switches master to standalone mode (worst case delay for writes ``ttl`` seconds, average case ``loop_wait``/2 seconds). Manually shutting down or restarting a standby will not cause a commit service interruption. Standby will signal the master to release itself from synchronous standby duties before PostgreSQL shutdown is initiated.
|
When ``synchronous_mode`` is on and a standby crashes, commits will block until next iteration of Patroni runs and switches the primary to standalone mode (worst case delay for writes ``ttl`` seconds, average case ``loop_wait``/2 seconds). Manually shutting down or restarting a standby will not cause a commit service interruption. Standby will signal the primary to release itself from synchronous standby duties before PostgreSQL shutdown is initiated.
|
||||||
|
|
||||||
|
When it is absolutely necessary to guarantee that each write is stored durably
|
||||||
|
on at least two nodes, enable ``synchronous_mode_strict`` in addition to the
|
||||||
|
``synchronous_node``. This parameter prevents Patroni from switching off the
|
||||||
|
synchronous replication on the primary when no synchronous standby candidates
|
||||||
|
are available. As a downside, the primary is not be available for writes
|
||||||
|
(unless the Postgres transaction explicitly turns of ``synchronous_mode``),
|
||||||
|
blocking all client write requests until at least one synchronous replica comes
|
||||||
|
up.
|
||||||
|
|
||||||
You can ensure that a standby never becomes the synchronous standby by setting ``nosync`` tag to true. This is recommended to set for standbys that are behind slow network connections and would cause performance degradation when becoming a synchronous standby.
|
You can ensure that a standby never becomes the synchronous standby by setting ``nosync`` tag to true. This is recommended to set for standbys that are behind slow network connections and would cause performance degradation when becoming a synchronous standby.
|
||||||
|
|
||||||
@@ -50,7 +59,7 @@ Synchronous mode can be switched on and off via Patroni REST interface. See :ref
|
|||||||
Synchronous mode implementation
|
Synchronous mode implementation
|
||||||
-------------------------------
|
-------------------------------
|
||||||
|
|
||||||
When in synchronous mode Patroni maintains synchronization state in the DCS, containing the latest master and current synchronous standby. This state is updated with strict ordering constraints to ensure the following invariants:
|
When in synchronous mode Patroni maintains synchronization state in the DCS, containing the latest primary and current synchronous standby. This state is updated with strict ordering constraints to ensure the following invariants:
|
||||||
|
|
||||||
- A node must be marked as the latest leader whenever it can accept write transactions. Patroni crashing or PostgreSQL not shutting down can cause violations of this invariant.
|
- A node must be marked as the latest leader whenever it can accept write transactions. Patroni crashing or PostgreSQL not shutting down can cause violations of this invariant.
|
||||||
|
|
||||||
@@ -60,9 +69,9 @@ When in synchronous mode Patroni maintains synchronization state in the DCS, con
|
|||||||
|
|
||||||
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.
|
Patroni will only ever assign one standby to ``synchronous_standby_names`` because with multiple candidates it is not possible to know which node was acting as synchronous during the failure.
|
||||||
|
|
||||||
On each HA loop iteration Patroni re-evaluates synchronous standby choice. If the current synchronous standby is connected and has not requested its synchronous status to be removed it remains picked. Otherwise the cluster member avaiable for sync that is furthest ahead in replication is picked.
|
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 master with the cluster.
|
.. [1] The data is still there, but recovering it requires a manual recovery effort by data recovery specialists. When Patroni is allowed to rewind with ``use_pg_rewind`` the forked timeline will be automatically erased to rejoin the failed primary with the cluster.
|
||||||
|
|
||||||
.. [2] Clients can change the behavior per transaction using PostgreSQL's ``synchronous_commit`` setting. Transactions with ``synchronous_commit`` values of ``off`` and ``local`` may be lost on fail over, but will not be blocked by replication delays.
|
.. [2] Clients can change the behavior per transaction using PostgreSQL's ``synchronous_commit`` setting. Transactions with ``synchronous_commit`` values of ``off`` and ``local`` may be lost on fail over, but will not be blocked by replication delays.
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ Having multiple PostgreSQL servers running as master can result in transactions
|
|||||||
|
|
||||||
- Shutting down PostgreSQL is too slow.
|
- Shutting down PostgreSQL is too slow.
|
||||||
|
|
||||||
- Patroni does not get to run due to high load on the system, th VM being paused by the hypervisor, or other infrastructure issues.
|
- Patroni does not get to run due to high load on the system, the VM being paused by the hypervisor, or other infrastructure issues.
|
||||||
|
|
||||||
To guarantee correct behavior under these conditions Patroni supports watchdog devices. Watchdog devices are software or hardware mechanisms that will reset the whole system when they do not get a keepalive heartbeat within a specified timeframe. This adds an additional layer of fail safe in case usual Patroni split-brain protection mechanisms fail.
|
To guarantee correct behavior under these conditions Patroni supports watchdog devices. Watchdog devices are software or hardware mechanisms that will reset the whole system when they do not get a keepalive heartbeat within a specified timeframe. This adds an additional layer of fail safe in case usual Patroni split-brain protection mechanisms fail.
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
### confd
|
### confd
|
||||||
|
|
||||||
`confd` directory contains haproxy template files for the [confd](https://github.com/kelseyhightower/confd) -- lightweight configuration management tool
|
`confd` directory contains haproxy and pgbouncer template files for the [confd](https://github.com/kelseyhightower/confd) -- lightweight configuration management tool
|
||||||
You need to copy content of `confd` directory into /etcd/confd and run confd service:
|
You need to copy content of `confd` directory into /etcd/confd and run confd service:
|
||||||
```bash
|
```bash
|
||||||
$ confd -prefix=/service/$PATRONI_SCOPE -backend etcd -node $PATRONI_ETCD_HOST -interval=10
|
$ confd -prefix=/service/$PATRONI_SCOPE -backend etcd -node $PATRONI_ETCD_URL -interval=10
|
||||||
```
|
```
|
||||||
It will periodically update haproxy.cfg with the actual list of Patroni nodes from `etcd` and "reload" haproxy when it is necessary.
|
It will periodically update haproxy.cfg and pgbouncer.ini with the actual list of Patroni nodes from `etcd` and "reload" haproxy and pgbouncer.ini when it is necessary.
|
||||||
|
|
||||||
|
|
||||||
### startup-scripts
|
### startup-scripts
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[template]
|
||||||
|
prefix = "/service/batman"
|
||||||
|
owner = "postgres"
|
||||||
|
mode = "0644"
|
||||||
|
src = "pgbouncer.tmpl"
|
||||||
|
dest = "/etc/pgbouncer/pgbouncer.ini"
|
||||||
|
|
||||||
|
reload_cmd = "systemctl reload pgbouncer"
|
||||||
|
|
||||||
|
keys = [
|
||||||
|
"/members/","/leader"
|
||||||
|
]
|
||||||
@@ -10,19 +10,23 @@ defaults
|
|||||||
timeout server 30m
|
timeout server 30m
|
||||||
timeout check 5s
|
timeout check 5s
|
||||||
|
|
||||||
frontend master_postgresql
|
listen stats
|
||||||
|
mode http
|
||||||
|
bind *:7000
|
||||||
|
stats enable
|
||||||
|
stats uri /
|
||||||
|
|
||||||
|
listen master
|
||||||
bind *:5000
|
bind *:5000
|
||||||
default_backend backend_master
|
|
||||||
|
|
||||||
frontend replicas_postgresql
|
|
||||||
bind *:5001
|
|
||||||
default_backend backend_replicas
|
|
||||||
|
|
||||||
backend backend_master
|
|
||||||
option httpchk OPTIONS /master
|
option httpchk OPTIONS /master
|
||||||
|
http-check expect status 200
|
||||||
|
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
|
||||||
{{range gets "/members/*"}} server {{base .Key}} {{$data := json .Value}}{{base (replace (index (split $data.conn_url "/") 2) "@" "/" -1)}} maxconn 100 check port {{index (split (index (split $data.api_url "/") 2) ":") 1}}
|
{{range gets "/members/*"}} server {{base .Key}} {{$data := json .Value}}{{base (replace (index (split $data.conn_url "/") 2) "@" "/" -1)}} maxconn 100 check port {{index (split (index (split $data.api_url "/") 2) ":") 1}}
|
||||||
{{end}}
|
{{end}}
|
||||||
backend backend_replicas
|
listen replicas
|
||||||
|
bind *:5001
|
||||||
option httpchk OPTIONS /replica
|
option httpchk OPTIONS /replica
|
||||||
|
http-check expect status 200
|
||||||
|
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
|
||||||
{{range gets "/members/*"}} server {{base .Key}} {{$data := json .Value}}{{base (replace (index (split $data.conn_url "/") 2) "@" "/" -1)}} maxconn 100 check port {{index (split (index (split $data.api_url "/") 2) ":") 1}}
|
{{range gets "/members/*"}} server {{base .Key}} {{$data := json .Value}}{{base (replace (index (split $data.conn_url "/") 2) "@" "/" -1)}} maxconn 100 check port {{index (split (index (split $data.api_url "/") 2) ":") 1}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[databases]
|
||||||
|
{{with get "/leader"}}{{$leader := .Value}}{{$leadkey := printf "/members/%s" $leader}}{{with get $leadkey}}{{$data := json .Value}}{{$hostport := base (replace (index (split $data.conn_url "/") 2) "@" "/" -1)}}{{ $host := base (index (split $hostport ":") 0)}}{{ $port := base (index (split $hostport ":") 1)}}* = host={{ $host }} port={{ $port }} pool_size=10{{end}}{{end}}
|
||||||
|
|
||||||
|
[pgbouncer]
|
||||||
|
logfile = /var/log/postgresql/pgbouncer.log
|
||||||
|
pidfile = /var/run/postgresql/pgbouncer.pid
|
||||||
|
listen_addr = *
|
||||||
|
listen_port = 6432
|
||||||
|
unix_socket_dir = /var/run/postgresql
|
||||||
|
auth_type = trust
|
||||||
|
auth_file = /etc/pgbouncer/userlist.txt
|
||||||
|
auth_hba_file = /etc/pgbouncer/pg_hba.txt
|
||||||
|
admin_users = pgbouncer
|
||||||
|
stats_users = pgbouncer
|
||||||
|
pool_mode = session
|
||||||
|
max_client_conn = 100
|
||||||
|
default_pool_size = 20
|
||||||
@@ -11,12 +11,26 @@ Type=simple
|
|||||||
User=postgres
|
User=postgres
|
||||||
Group=postgres
|
Group=postgres
|
||||||
|
|
||||||
|
# Read in configuration file if it exists, otherwise proceed
|
||||||
|
EnvironmentFile=-/etc/patroni_env.conf
|
||||||
|
|
||||||
|
WorkingDirectory=~
|
||||||
|
|
||||||
# Where to send early-startup messages from the server
|
# Where to send early-startup messages from the server
|
||||||
# This is normally controlled by the global default set by systemd
|
# This is normally controlled by the global default set by systemd
|
||||||
# StandardOutput=syslog
|
#StandardOutput=syslog
|
||||||
|
|
||||||
|
# Pre-commands to start watchdog device
|
||||||
|
# Uncomment if watchdog is part of your patroni setup
|
||||||
|
#ExecStartPre=-/usr/bin/sudo /sbin/modprobe softdog
|
||||||
|
#ExecStartPre=-/usr/bin/sudo /bin/chown postgres /dev/watchdog
|
||||||
|
|
||||||
|
# Start the patroni process
|
||||||
ExecStart=/bin/patroni /etc/patroni.yml
|
ExecStart=/bin/patroni /etc/patroni.yml
|
||||||
|
|
||||||
|
# Send HUP to reload from patroni.yml
|
||||||
|
ExecReload=/bin/kill -s HUP $MAINPID
|
||||||
|
|
||||||
# only kill the patroni process, not it's children, so it will gracefully stop postgres
|
# only kill the patroni process, not it's children, so it will gracefully stop postgres
|
||||||
KillMode=process
|
KillMode=process
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ Feature: basic replication
|
|||||||
Then table foo is present on postgres2 after 20 seconds
|
Then table foo is present on postgres2 after 20 seconds
|
||||||
|
|
||||||
Scenario: check restart of sync replica
|
Scenario: check restart of sync replica
|
||||||
Given I run patronictl.py restart batman postgres2 --force
|
Given I shut down postgres2
|
||||||
And "sync" key in DCS has sync_standby=postgres1 after 2 seconds
|
Then "sync" key in DCS has sync_standby=postgres1 after 5 seconds
|
||||||
And I run patronictl.py restart batman postgres1 --force
|
When I start postgres2
|
||||||
Then I receive a response returncode 0
|
And I shut down postgres1
|
||||||
And "sync" key in DCS has sync_standby=postgres2 after 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
|
||||||
And I sleep for 2 seconds
|
And I sleep for 2 seconds
|
||||||
When I issue a GET request to http://127.0.0.1:8010/sync
|
When I issue a GET request to http://127.0.0.1:8010/sync
|
||||||
Then I receive a response code 200
|
Then I receive a response code 200
|
||||||
@@ -30,7 +32,7 @@ Feature: basic replication
|
|||||||
Then I receive a response returncode 0
|
Then I receive a response returncode 0
|
||||||
When I sleep for 2 seconds
|
When I sleep for 2 seconds
|
||||||
And I shut down postgres0
|
And I shut down postgres0
|
||||||
And I run patronictl.py resume batman
|
And I run patronictl.py resume batman
|
||||||
Then I receive a response returncode 0
|
Then I receive a response returncode 0
|
||||||
And postgres2 role is the primary after 24 seconds
|
And postgres2 role is the primary after 24 seconds
|
||||||
When I issue a PATCH request to http://127.0.0.1:8010/config with {"synchronous_mode": null, "master_start_timeout": 0}
|
When I issue a PATCH request to http://127.0.0.1:8010/config with {"synchronous_mode": null, "master_start_timeout": 0}
|
||||||
|
|||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
[[ "$3" == "master" ]] || exit
|
||||||
|
|
||||||
|
PGPASSWORD=zalando psql -h localhost -U postgres -p $1 -w -tAc "SELECT slot_name FROM pg_replication_slots WHERE slot_type = 'logical'" >> data/postgres0/label
|
||||||
@@ -182,6 +182,9 @@ class PatroniController(AbstractController):
|
|||||||
dst[k] = v
|
dst[k] = v
|
||||||
recursive_update(config, custom_config)
|
recursive_update(config, custom_config)
|
||||||
|
|
||||||
|
if config['postgresql'].get('callbacks', {}).get('on_role_change'):
|
||||||
|
config['postgresql']['callbacks']['on_role_change'] += ' ' + str(self.__PORT)
|
||||||
|
|
||||||
with open(patroni_config_path, 'w') as f:
|
with open(patroni_config_path, 'w') as f:
|
||||||
yaml.safe_dump(config, f, default_flow_style=False)
|
yaml.safe_dump(config, f, default_flow_style=False)
|
||||||
|
|
||||||
@@ -612,6 +615,13 @@ class PatroniPoolController(object):
|
|||||||
'method': 'pg_basebackup',
|
'method': 'pg_basebackup',
|
||||||
'pg_basebackup': {
|
'pg_basebackup': {
|
||||||
'command': self.BACKUP_SCRIPT + ' --walmethod=stream --dbname=' + f.backup_source
|
'command': self.BACKUP_SCRIPT + ' --walmethod=stream --dbname=' + f.backup_source
|
||||||
|
},
|
||||||
|
'dcs': {
|
||||||
|
'postgresql': {
|
||||||
|
'parameters': {
|
||||||
|
'max_connections': 101
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'postgresql': {
|
'postgresql': {
|
||||||
|
|||||||
@@ -71,6 +71,14 @@ Scenario: check the switchover via the API in the pause mode
|
|||||||
And postgres1 role is the primary after 10 seconds
|
And postgres1 role is the primary after 10 seconds
|
||||||
And postgres0 role is the secondary after 10 seconds
|
And postgres0 role is the secondary after 10 seconds
|
||||||
And replication works from postgres1 to postgres0 after 20 seconds
|
And replication works from postgres1 to postgres0 after 20 seconds
|
||||||
|
When I issue a GET request to http://127.0.0.1:8008/master
|
||||||
|
Then I receive a response code 503
|
||||||
|
When I issue a GET request to http://127.0.0.1:8008/replica
|
||||||
|
Then I receive a response code 200
|
||||||
|
When I issue a GET request to http://127.0.0.1:8009/master
|
||||||
|
Then I receive a response code 200
|
||||||
|
When I issue a GET request to http://127.0.0.1:8009/replica
|
||||||
|
Then I receive a response code 503
|
||||||
|
|
||||||
Scenario: check the scheduled switchover
|
Scenario: check the scheduled switchover
|
||||||
Given I issue a scheduled switchover from postgres1 to postgres0 in 3 seconds
|
Given I issue a scheduled switchover from postgres1 to postgres0 in 3 seconds
|
||||||
@@ -84,6 +92,14 @@ Scenario: check the scheduled switchover
|
|||||||
And postgres0 role is the primary after 10 seconds
|
And postgres0 role is the primary after 10 seconds
|
||||||
And postgres1 role is the secondary after 10 seconds
|
And postgres1 role is the secondary after 10 seconds
|
||||||
And replication works from postgres0 to postgres1 after 25 seconds
|
And replication works from postgres0 to postgres1 after 25 seconds
|
||||||
|
When I issue a GET request to http://127.0.0.1:8008/master
|
||||||
|
Then I receive a response code 200
|
||||||
|
When I issue a GET request to http://127.0.0.1:8008/replica
|
||||||
|
Then I receive a response code 503
|
||||||
|
When I issue a GET request to http://127.0.0.1:8009/master
|
||||||
|
Then I receive a response code 503
|
||||||
|
When I issue a GET request to http://127.0.0.1:8009/replica
|
||||||
|
Then I receive a response code 200
|
||||||
|
|
||||||
Scenario: check the scheduled restart
|
Scenario: check the scheduled restart
|
||||||
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"superuser_reserved_connections": "6"}}}
|
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"superuser_reserved_connections": "6"}}}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
Feature: standby cluster
|
||||||
|
Scenario: check permanent logical slots are preserved on failover/switchover
|
||||||
|
Given I start postgres1
|
||||||
|
Then postgres1 is a leader after 10 seconds
|
||||||
|
And I sleep for 2 seconds
|
||||||
|
When I issue a PATCH request to http://127.0.0.1:8009/config with {"slots": {"pm_1": {"type": "physical"}}, "postgresql": {"parameters": {"wal_level": "logical"}}}
|
||||||
|
Then I receive a response code 200
|
||||||
|
And Response on GET http://127.0.0.1:8009/config contains slots after 10 seconds
|
||||||
|
When I issue a PATCH request to http://127.0.0.1:8009/config with {"slots": {"test_logical": {"type": "logical", "database": "postgres", "plugin": "test_decoding"}}}
|
||||||
|
Then I receive a response code 200
|
||||||
|
When I start postgres0 with callback configured
|
||||||
|
Then "members/postgres0" key in DCS has state=running after 10 seconds
|
||||||
|
When I shut down postgres1
|
||||||
|
Then postgres0 is a leader after 10 seconds
|
||||||
|
And I sleep for 2 seconds
|
||||||
|
When I issue a GET request to http://127.0.0.1:8008/
|
||||||
|
Then I receive a response code 200
|
||||||
|
And there is a label with "test_logical" in postgres0 data directory
|
||||||
|
|
||||||
|
Scenario: check replication of a single table in a standby cluster
|
||||||
|
Given I start postgres1 in a standby cluster batman1 as a clone of postgres0
|
||||||
|
Then postgres1 is a leader of batman1 after 10 seconds
|
||||||
|
When I issue a PATCH request to http://127.0.0.1:8009/config with {"ttl": 20, "loop_wait": 2}
|
||||||
|
And I add the table foo to postgres0
|
||||||
|
Then table foo is present on postgres1 after 20 seconds
|
||||||
|
When I start postgres2 in a cluster batman1
|
||||||
|
Then postgres2 role is the replica after 24 seconds
|
||||||
|
And table foo is present on postgres2 after 20 seconds
|
||||||
|
|
||||||
|
Scenario: check failover
|
||||||
|
When I kill postgres1
|
||||||
|
And I kill postmaster on postgres1
|
||||||
|
Then postgres2 is replicating from postgres0 after 20 seconds
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from behave import step
|
||||||
|
|
||||||
|
|
||||||
|
select_replication_query = """
|
||||||
|
SELECT * FROM pg_catalog.pg_stat_replication
|
||||||
|
WHERE application_name = '{0}'
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@step('I start {name:w} with callback configured')
|
||||||
|
def start_patroni_with_callbacks(context, name):
|
||||||
|
return context.pctl.start(name, custom_config={
|
||||||
|
"postgresql": {
|
||||||
|
"callbacks": {
|
||||||
|
"on_role_change": "features/callback.sh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@step('I start {name:w} in a cluster {cluster_name:w}')
|
||||||
|
def start_patroni(context, name, cluster_name):
|
||||||
|
return context.pctl.start(name, custom_config={
|
||||||
|
"scope": cluster_name
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@step('I start {name:w} in a standby cluster {cluster_name:w} as a clone of {name2:w}')
|
||||||
|
def start_patroni_stanby_cluster(context, name, cluster_name, name2):
|
||||||
|
ctl = context.pctl._processes.pop(name, None)
|
||||||
|
# we need to remove patroni.dynamic.json in order to "bootstrap" standby cluster with existing PGDATA
|
||||||
|
if ctl:
|
||||||
|
os.unlink(os.path.join(ctl._data_dir, 'patroni.dynamic.json'))
|
||||||
|
port = context.pctl._processes[name2]._connkwargs.get('port')
|
||||||
|
return context.pctl.start(name, custom_config={
|
||||||
|
"scope": cluster_name,
|
||||||
|
"bootstrap": {
|
||||||
|
"dcs": {
|
||||||
|
"standby_cluster": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": port,
|
||||||
|
"primary_slot_name": "pm_1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@step('{pg_name1:w} is replicating from {pg_name2:w} after {timeout:d} seconds')
|
||||||
|
def check_replication_status(context, pg_name1, pg_name2, timeout):
|
||||||
|
bound_time = time.time() + timeout
|
||||||
|
|
||||||
|
while time.time() < bound_time:
|
||||||
|
cur = context.pctl.query(
|
||||||
|
pg_name2,
|
||||||
|
select_replication_query.format(pg_name1),
|
||||||
|
fail_ok=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if cur and len(cur.fetchall()) != 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
return False
|
||||||
+17
-10
@@ -1,32 +1,39 @@
|
|||||||
FROM postgres:9.6
|
FROM postgres:10
|
||||||
MAINTAINER Alexander Kukushkin <[email protected]>
|
MAINTAINER Alexander Kukushkin <[email protected]>
|
||||||
|
|
||||||
RUN export DEBIAN_FRONTEND=noninteractive \
|
RUN export DEBIAN_FRONTEND=noninteractive \
|
||||||
&& echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";' > /etc/apt/apt.conf.d/01norecommend \
|
&& echo 'APT::Install-Recommends "0";\nAPT::Install-Suggests "0";' > /etc/apt/apt.conf.d/01norecommend \
|
||||||
&& apt-get update -y \
|
&& apt-get update -y \
|
||||||
&& apt-get upgrade -y \
|
&& apt-get upgrade -y \
|
||||||
&& apt-get install -y git curl jq python-psycopg2 python-yaml python-requests python-six python-pysocks \
|
&& apt-cache depends patroni | sed -n -e 's/.* Depends: \(python3-.\+\)$/\1/p' \
|
||||||
python-dateutil python-pip python-prettytable python-wheel python-psutil python locales \
|
| grep -Ev '^python3-(sphinx|etcd|consul|kazoo|kubernetes)' \
|
||||||
|
| xargs apt-get install -y curl jq locales git python3-pip python3-wheel \
|
||||||
|
|
||||||
## Make sure we have a en_US.UTF-8 locale available
|
## Make sure we have a en_US.UTF-8 locale available
|
||||||
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
|
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
|
||||||
|
|
||||||
&& pip install setuptools pip --upgrade \
|
&& pip3 install setuptools \
|
||||||
&& pip install 'git+https://github.com/zalando/patroni.git@feature/k8s#egg=patroni[kubernetes]' \
|
&& pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
|
||||||
|
|
||||||
&& mkdir -p /home/postgres \
|
&& PGHOME=/home/postgres \
|
||||||
&& chown postgres:postgres /home/postgres \
|
&& mkdir -p $PGHOME \
|
||||||
|
&& chown postgres $PGHOME \
|
||||||
|
&& sed -i "s|/var/lib/postgresql.*|$PGHOME:/bin/bash|" /etc/passwd \
|
||||||
|
|
||||||
|
# Set permissions for OpenShift
|
||||||
|
&& chmod 775 $PGHOME \
|
||||||
|
&& chmod 664 /etc/passwd \
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
&& apt-get remove -y git python-pip python-setuptools \
|
&& apt-get remove -y git python3-pip python3-wheel \
|
||||||
&& apt-get autoremove -y \
|
&& apt-get autoremove -y \
|
||||||
&& apt-get clean -y \
|
&& apt-get clean -y \
|
||||||
&& rm -rf /var/lib/apt/lists/* /root/.cache
|
&& rm -rf /var/lib/apt/lists/* /root/.cache
|
||||||
|
|
||||||
ADD entrypoint.sh callback.py /
|
ADD entrypoint.sh /
|
||||||
|
|
||||||
EXPOSE 5432 8008
|
EXPOSE 5432 8008
|
||||||
ENV LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8
|
ENV LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8
|
||||||
USER postgres
|
USER postgres
|
||||||
WORKDIR /home/postgres
|
WORKDIR /home/postgres
|
||||||
CMD ["/bin/bash", "/entrypoint.sh"]
|
CMD ["/bin/bash", "/entrypoint.sh"]
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
from kubernetes import client as k8s_client, config as k8s_config
|
|
||||||
from urllib3.exceptions import HTTPError
|
|
||||||
from six.moves.http_client import HTTPException
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CoreV1Api(k8s_client.CoreV1Api):
|
|
||||||
|
|
||||||
def retry(func):
|
|
||||||
def wrapped(*args, **kwargs):
|
|
||||||
count = 0
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
except (HTTPException, HTTPError, socket.error, socket.timeout):
|
|
||||||
if count >= 10:
|
|
||||||
raise
|
|
||||||
logger.info('Throttling API requests...')
|
|
||||||
time.sleep(2 ** count * 0.5)
|
|
||||||
count += 1
|
|
||||||
return wrapped
|
|
||||||
|
|
||||||
@retry
|
|
||||||
def patch_namespaced_endpoints(self, *args, **kwargs):
|
|
||||||
return super(CoreV1Api, self).patch_namespaced_endpoints(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def patch_master_endpoint(api, namespace, cluster):
|
|
||||||
addresses = [k8s_client.V1EndpointAddress(ip=os.environ['POD_IP'])]
|
|
||||||
ports = [k8s_client.V1EndpointPort(port=5432)]
|
|
||||||
subsets = [k8s_client.V1EndpointSubset(addresses=addresses, ports=ports)]
|
|
||||||
body = k8s_client.V1Endpoints(subsets=subsets)
|
|
||||||
return api.patch_namespaced_endpoints(cluster, namespace, body)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO)
|
|
||||||
if len(sys.argv) != 4 or sys.argv[1] not in ('on_start', 'on_stop', 'on_role_change'):
|
|
||||||
sys.exit('Usage: %s <action> <role> <cluster_name>', sys.argv[0])
|
|
||||||
|
|
||||||
action, role, cluster = sys.argv[1:4]
|
|
||||||
|
|
||||||
k8s_config.load_incluster_config()
|
|
||||||
k8s_api = CoreV1Api()
|
|
||||||
|
|
||||||
namespace = os.environ['KUBERNETES_NAMESPACE']
|
|
||||||
|
|
||||||
if role == 'master' and action in ('on_start', 'on_role_change'):
|
|
||||||
patch_master_endpoint(k8s_api, namespace, cluster)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
if [[ $UID -ge 10000 ]]; then
|
||||||
|
GID=$(id -g)
|
||||||
|
sed -e "s/^postgres:x:[^:]*:[^:]*:/postgres:x:$UID:$GID:/" /etc/passwd > /tmp/passwd
|
||||||
|
cat /tmp/passwd > /etc/passwd
|
||||||
|
rm /tmp/passwd
|
||||||
|
fi
|
||||||
|
|
||||||
cat > /home/postgres/patroni.yml <<__EOF__
|
cat > /home/postgres/patroni.yml <<__EOF__
|
||||||
bootstrap:
|
bootstrap:
|
||||||
dcs:
|
dcs:
|
||||||
@@ -23,14 +30,10 @@ postgresql:
|
|||||||
password: '${PATRONI_SUPERUSER_PASSWORD}'
|
password: '${PATRONI_SUPERUSER_PASSWORD}'
|
||||||
replication:
|
replication:
|
||||||
password: '${PATRONI_REPLICATION_PASSWORD}'
|
password: '${PATRONI_REPLICATION_PASSWORD}'
|
||||||
callbacks:
|
|
||||||
on_start: /callback.py
|
|
||||||
on_stop: /callback.py
|
|
||||||
on_role_change: /callback.py
|
|
||||||
__EOF__
|
__EOF__
|
||||||
|
|
||||||
unset PATRONI_SUPERUSER_PASSWORD PATRONI_REPLICATION_PASSWORD
|
unset PATRONI_SUPERUSER_PASSWORD PATRONI_REPLICATION_PASSWORD
|
||||||
export KUBERNETES_NAMESPACE=$PATRONI_KUBERNETES_NAMESPACE
|
export KUBERNETES_NAMESPACE=$PATRONI_KUBERNETES_NAMESPACE
|
||||||
export POD_NAME=$PATRONI_NAME
|
export POD_NAME=$PATRONI_NAME
|
||||||
|
|
||||||
exec /usr/bin/python /usr/local/bin/patroni /home/postgres/patroni.yml
|
exec /usr/bin/python3 /usr/local/bin/patroni /home/postgres/patroni.yml
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Patroni OpenShift Configuration
|
||||||
|
Patroni can be run in OpenShift. Based on the kubernetes configuration, the Dockerfile and Entrypoint has been modified to support the dynamic UID/GID configuration that is applied in OpenShift. This can be run under the standard `restricted` SCC.
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
|
||||||
|
## Create test project
|
||||||
|
|
||||||
|
```
|
||||||
|
oc new-project patroni-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build the image
|
||||||
|
|
||||||
|
Note: Update the references when merged upstream.
|
||||||
|
Note: If deploying as a template for multiple users, the following commands should be performed in a shared namespace like `openshift`.
|
||||||
|
|
||||||
|
```
|
||||||
|
oc import-image postgres:10 --confirm -n openshift
|
||||||
|
oc new-build https://github.com/zalando/patroni --context-dir=kubernetes -n openshift
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy the Image
|
||||||
|
Two configuration templates exist in [templates](templates) directory:
|
||||||
|
- Patroni Ephemeral
|
||||||
|
- Patroni Persistent
|
||||||
|
|
||||||
|
The only difference is whether or not the statefulset requests persistent storage.
|
||||||
|
|
||||||
|
## Create the Template
|
||||||
|
Install the template into the `openshift` namespace if this should be shared across projects:
|
||||||
|
|
||||||
|
```
|
||||||
|
oc create -f templates/template_patroni_ephemeral.yml -n openshift
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, from your own project:
|
||||||
|
|
||||||
|
```
|
||||||
|
oc new-app patroni-pgsql-ephemeral
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the pods are running, two configmaps should be available:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ oc get configmap
|
||||||
|
NAME DATA AGE
|
||||||
|
patroniocp-config 0 1m
|
||||||
|
patroniocp-leader 0 1m
|
||||||
|
```
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Template
|
||||||
|
metadata:
|
||||||
|
name: patroni-pgsql-ephemeral
|
||||||
|
annotations:
|
||||||
|
description: |-
|
||||||
|
Patroni Postgresql database cluster, without persistent storage.
|
||||||
|
|
||||||
|
WARNING: Any data stored will be lost upon pod destruction. Only use this template for testing.
|
||||||
|
iconClass: icon-postgresql
|
||||||
|
openshift.io/display-name: Patroni Postgresql (Ephemeral)
|
||||||
|
openshift.io/long-description: This template deploys a a patroni postgresql HA cluster without persistent storage.
|
||||||
|
tags: postgresql
|
||||||
|
objects:
|
||||||
|
- apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
creationTimestamp: null
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
protocol: TCP
|
||||||
|
targetPort: 5432
|
||||||
|
sessionAffinity: None
|
||||||
|
type: ClusterIP
|
||||||
|
status:
|
||||||
|
loadBalancer: {}
|
||||||
|
- apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
creationTimestamp: null
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
name: ${PATRONI_MASTER_SERVICE_NAME}
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
protocol: TCP
|
||||||
|
targetPort: 5432
|
||||||
|
selector:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
role: master
|
||||||
|
sessionAffinity: None
|
||||||
|
type: ClusterIP
|
||||||
|
status:
|
||||||
|
loadBalancer: {}
|
||||||
|
- apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
stringData:
|
||||||
|
superuser-password: ${PATRONI_SUPERUSER_PASSWORD}
|
||||||
|
replication-password: ${PATRONI_REPLICATION_PASSWORD}
|
||||||
|
- apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
creationTimestamp: null
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
name: ${PATRONI_REPLICA_SERVICE_NAME}
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
protocol: TCP
|
||||||
|
targetPort: 5432
|
||||||
|
selector:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
role: replica
|
||||||
|
sessionAffinity: None
|
||||||
|
type: ClusterIP
|
||||||
|
status:
|
||||||
|
loadBalancer: {}
|
||||||
|
- apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
creationTimestamp: null
|
||||||
|
generation: 3
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
name: ${APPLICATION_NAME}
|
||||||
|
spec:
|
||||||
|
podManagementPolicy: OrderedReady
|
||||||
|
replicas: 3
|
||||||
|
revisionHistoryLimit: 10
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
serviceName: ${APPLICATION_NAME}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
creationTimestamp: null
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- env:
|
||||||
|
- name: POD_IP
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
apiVersion: v1
|
||||||
|
fieldPath: status.podIP
|
||||||
|
- name: PATRONI_KUBERNETES_NAMESPACE
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
apiVersion: v1
|
||||||
|
fieldPath: metadata.namespace
|
||||||
|
- name: PATRONI_KUBERNETES_LABELS
|
||||||
|
value: '{application: ${APPLICATION_NAME}, cluster-name: ${PATRONI_CLUSTER_NAME}}'
|
||||||
|
- name: PATRONI_SUPERUSER_USERNAME
|
||||||
|
value: ${PATRONI_SUPERUSER_USERNAME}
|
||||||
|
- name: PATRONI_SUPERUSER_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
key: superuser-password
|
||||||
|
name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
- name: PATRONI_REPLICATION_USERNAME
|
||||||
|
value: ${PATRONI_REPLICATION_USERNAME}
|
||||||
|
- name: PATRONI_REPLICATION_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
key: replication-password
|
||||||
|
name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
- name: PATRONI_SCOPE
|
||||||
|
value: ${PATRONI_CLUSTER_NAME}
|
||||||
|
- name: PATRONI_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
apiVersion: v1
|
||||||
|
fieldPath: metadata.name
|
||||||
|
- name: PATRONI_POSTGRESQL_DATA_DIR
|
||||||
|
value: /home/postgres/pgdata/pgroot/data
|
||||||
|
- name: PATRONI_POSTGRESQL_PGPASS
|
||||||
|
value: /tmp/pgpass
|
||||||
|
- name: PATRONI_POSTGRESQL_LISTEN
|
||||||
|
value: 0.0.0.0:5432
|
||||||
|
- name: PATRONI_RESTAPI_LISTEN
|
||||||
|
value: 0.0.0.0:8008
|
||||||
|
image: docker-registry.default.svc:5000/${NAMESPACE}/patroni:latest
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
name: ${APPLICATION_NAME}
|
||||||
|
ports:
|
||||||
|
- containerPort: 8008
|
||||||
|
protocol: TCP
|
||||||
|
- containerPort: 5432
|
||||||
|
protocol: TCP
|
||||||
|
resources: {}
|
||||||
|
terminationMessagePath: /dev/termination-log
|
||||||
|
terminationMessagePolicy: File
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: /home/postgres/pgdata
|
||||||
|
name: pgdata
|
||||||
|
dnsPolicy: ClusterFirst
|
||||||
|
restartPolicy: Always
|
||||||
|
schedulerName: default-scheduler
|
||||||
|
securityContext: {}
|
||||||
|
serviceAccount: ${SERVICE_ACCOUNT}
|
||||||
|
serviceAccountName: ${SERVICE_ACCOUNT}
|
||||||
|
terminationGracePeriodSeconds: 0
|
||||||
|
volumes:
|
||||||
|
- name: pgdata
|
||||||
|
emptyDir: {}
|
||||||
|
updateStrategy:
|
||||||
|
type: OnDelete
|
||||||
|
- apiVersion: v1
|
||||||
|
kind: Endpoints
|
||||||
|
metadata:
|
||||||
|
name: ${APPLICATION_NAME}
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
subsets: []
|
||||||
|
- apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: ${SERVICE_ACCOUNT}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: ${SERVICE_ACCOUNT}
|
||||||
|
rules:
|
||||||
|
- apiGroups:
|
||||||
|
- ""
|
||||||
|
resources:
|
||||||
|
- configmaps
|
||||||
|
verbs:
|
||||||
|
- create
|
||||||
|
- get
|
||||||
|
- list
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
- watch
|
||||||
|
# delete is required only for 'patronictl remove'
|
||||||
|
- delete
|
||||||
|
- apiGroups:
|
||||||
|
- ""
|
||||||
|
resources:
|
||||||
|
- endpoints
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
# the following three privileges are necessary only when using endpoints
|
||||||
|
- create
|
||||||
|
- list
|
||||||
|
- watch
|
||||||
|
# delete is required only for for 'patronictl remove'
|
||||||
|
- delete
|
||||||
|
- apiGroups:
|
||||||
|
- ""
|
||||||
|
resources:
|
||||||
|
- pods
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
|
- list
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
- watch
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: ${SERVICE_ACCOUNT}
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: ${SERVICE_ACCOUNT}
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: ${SERVICE_ACCOUNT}
|
||||||
|
parameters:
|
||||||
|
- description: The name of the application for labelling all artifacts.
|
||||||
|
displayName: Application Name
|
||||||
|
name: APPLICATION_NAME
|
||||||
|
value: patroni-ephemeral
|
||||||
|
- description: The name of the patroni-pgsql cluster.
|
||||||
|
displayName: Cluster Name
|
||||||
|
name: PATRONI_CLUSTER_NAME
|
||||||
|
value: patroni-ephemeral
|
||||||
|
- description: The name of the OpenShift Service exposed for the patroni-ephemeral-master container.
|
||||||
|
displayName: Master service name.
|
||||||
|
name: PATRONI_MASTER_SERVICE_NAME
|
||||||
|
value: patroni-ephemeral-master
|
||||||
|
- description: The name of the OpenShift Service exposed for the patroni-ephemeral-replica containers.
|
||||||
|
displayName: Replica service name.
|
||||||
|
name: PATRONI_REPLICA_SERVICE_NAME
|
||||||
|
value: patroni-ephemeral-replica
|
||||||
|
- description: Maximum amount of memory the container can use.
|
||||||
|
displayName: Memory Limit
|
||||||
|
name: MEMORY_LIMIT
|
||||||
|
value: 512Mi
|
||||||
|
- description: The OpenShift Namespace where the patroni and postgresql ImageStream resides.
|
||||||
|
displayName: ImageStream Namespace
|
||||||
|
name: NAMESPACE
|
||||||
|
value: openshift
|
||||||
|
- description: Username of the superuser account for initialization.
|
||||||
|
displayName: Superuser Username
|
||||||
|
name: PATRONI_SUPERUSER_USERNAME
|
||||||
|
value: postgres
|
||||||
|
- description: Password of the superuser account for initialization.
|
||||||
|
displayName: Superuser Passsword
|
||||||
|
name: PATRONI_SUPERUSER_PASSWORD
|
||||||
|
value: postgres
|
||||||
|
- description: Username of the replication account for initialization.
|
||||||
|
displayName: Replication Username
|
||||||
|
name: PATRONI_REPLICATION_USERNAME
|
||||||
|
value: postgres
|
||||||
|
- description: Password of the replication account for initialization.
|
||||||
|
displayName: Repication Passsword
|
||||||
|
name: PATRONI_REPLICATION_PASSWORD
|
||||||
|
value: postgres
|
||||||
|
- description: Service account name used for pods and rolebindings to form a cluster in the project.
|
||||||
|
displayName: Service Account
|
||||||
|
name: SERVICE_ACCOUNT
|
||||||
|
value: patroniocp
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Template
|
||||||
|
metadata:
|
||||||
|
name: patroni-pgsql-persistent
|
||||||
|
annotations:
|
||||||
|
description: |-
|
||||||
|
Patroni Postgresql database cluster, with persistent storage.
|
||||||
|
|
||||||
|
WARNING: Any data stored will be lost upon pod destruction. Only use this template for testing.
|
||||||
|
iconClass: icon-postgresql
|
||||||
|
openshift.io/display-name: Patroni Postgresql (Persistent)
|
||||||
|
openshift.io/long-description: This template deploys a a patroni postgresql HA cluster without persistent storage.
|
||||||
|
tags: postgresql
|
||||||
|
objects:
|
||||||
|
- apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
creationTimestamp: null
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
protocol: TCP
|
||||||
|
targetPort: 5432
|
||||||
|
sessionAffinity: None
|
||||||
|
type: ClusterIP
|
||||||
|
status:
|
||||||
|
loadBalancer: {}
|
||||||
|
- apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
creationTimestamp: null
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
name: ${PATRONI_MASTER_SERVICE_NAME}
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
protocol: TCP
|
||||||
|
targetPort: 5432
|
||||||
|
selector:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
role: master
|
||||||
|
sessionAffinity: None
|
||||||
|
type: ClusterIP
|
||||||
|
status:
|
||||||
|
loadBalancer: {}
|
||||||
|
- apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
stringData:
|
||||||
|
superuser-password: ${PATRONI_SUPERUSER_PASSWORD}
|
||||||
|
replication-password: ${PATRONI_REPLICATION_PASSWORD}
|
||||||
|
- apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
creationTimestamp: null
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
name: ${PATRONI_REPLICA_SERVICE_NAME}
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
protocol: TCP
|
||||||
|
targetPort: 5432
|
||||||
|
selector:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
role: replica
|
||||||
|
sessionAffinity: None
|
||||||
|
type: ClusterIP
|
||||||
|
status:
|
||||||
|
loadBalancer: {}
|
||||||
|
- apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
creationTimestamp: null
|
||||||
|
generation: 3
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
name: ${APPLICATION_NAME}
|
||||||
|
spec:
|
||||||
|
podManagementPolicy: OrderedReady
|
||||||
|
replicas: 3
|
||||||
|
revisionHistoryLimit: 10
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
serviceName: ${APPLICATION_NAME}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
creationTimestamp: null
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- env:
|
||||||
|
- name: POD_IP
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
apiVersion: v1
|
||||||
|
fieldPath: status.podIP
|
||||||
|
- name: PATRONI_KUBERNETES_NAMESPACE
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
apiVersion: v1
|
||||||
|
fieldPath: metadata.namespace
|
||||||
|
- name: PATRONI_KUBERNETES_LABELS
|
||||||
|
value: '{application: ${APPLICATION_NAME}, cluster-name: ${PATRONI_CLUSTER_NAME}}'
|
||||||
|
- name: PATRONI_SUPERUSER_USERNAME
|
||||||
|
value: ${PATRONI_SUPERUSER_USERNAME}
|
||||||
|
- name: PATRONI_SUPERUSER_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
key: superuser-password
|
||||||
|
name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
- name: PATRONI_REPLICATION_USERNAME
|
||||||
|
value: ${PATRONI_REPLICATION_USERNAME}
|
||||||
|
- name: PATRONI_REPLICATION_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
key: replication-password
|
||||||
|
name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
- name: PATRONI_SCOPE
|
||||||
|
value: ${PATRONI_CLUSTER_NAME}
|
||||||
|
- name: PATRONI_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
apiVersion: v1
|
||||||
|
fieldPath: metadata.name
|
||||||
|
- name: PATRONI_POSTGRESQL_DATA_DIR
|
||||||
|
value: /home/postgres/pgdata/pgroot/data
|
||||||
|
- name: PATRONI_POSTGRESQL_PGPASS
|
||||||
|
value: /tmp/pgpass
|
||||||
|
- name: PATRONI_POSTGRESQL_LISTEN
|
||||||
|
value: 0.0.0.0:5432
|
||||||
|
- name: PATRONI_RESTAPI_LISTEN
|
||||||
|
value: 0.0.0.0:8008
|
||||||
|
image: docker-registry.default.svc:5000/${NAMESPACE}/patroni:latest
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
name: ${APPLICATION_NAME}
|
||||||
|
ports:
|
||||||
|
- containerPort: 8008
|
||||||
|
protocol: TCP
|
||||||
|
- containerPort: 5432
|
||||||
|
protocol: TCP
|
||||||
|
resources: {}
|
||||||
|
terminationMessagePath: /dev/termination-log
|
||||||
|
terminationMessagePolicy: File
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: /home/postgres/pgdata
|
||||||
|
name: ${APPLICATION_NAME}
|
||||||
|
dnsPolicy: ClusterFirst
|
||||||
|
restartPolicy: Always
|
||||||
|
schedulerName: default-scheduler
|
||||||
|
securityContext: {}
|
||||||
|
serviceAccount: ${SERVICE_ACCOUNT}
|
||||||
|
serviceAccountName: ${SERVICE_ACCOUNT}
|
||||||
|
terminationGracePeriodSeconds: 0
|
||||||
|
volumes:
|
||||||
|
- name: ${APPLICATION_NAME}
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: ${APPLICATION_NAME}
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
name: ${APPLICATION_NAME}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: ${PVC_SIZE}
|
||||||
|
updateStrategy:
|
||||||
|
type: OnDelete
|
||||||
|
- apiVersion: v1
|
||||||
|
kind: Endpoints
|
||||||
|
metadata:
|
||||||
|
name: ${APPLICATION_NAME}
|
||||||
|
labels:
|
||||||
|
application: ${APPLICATION_NAME}
|
||||||
|
cluster-name: ${PATRONI_CLUSTER_NAME}
|
||||||
|
subsets: []
|
||||||
|
- apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: ${SERVICE_ACCOUNT}
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: ${SERVICE_ACCOUNT}
|
||||||
|
rules:
|
||||||
|
- apiGroups:
|
||||||
|
- ""
|
||||||
|
resources:
|
||||||
|
- configmaps
|
||||||
|
verbs:
|
||||||
|
- create
|
||||||
|
- get
|
||||||
|
- list
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
- watch
|
||||||
|
# delete is required only for 'patronictl remove'
|
||||||
|
- delete
|
||||||
|
- apiGroups:
|
||||||
|
- ""
|
||||||
|
resources:
|
||||||
|
- endpoints
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
# the following three privileges are necessary only when using endpoints
|
||||||
|
- create
|
||||||
|
- list
|
||||||
|
- watch
|
||||||
|
# delete is required only for for 'patronictl remove'
|
||||||
|
- delete
|
||||||
|
- apiGroups:
|
||||||
|
- ""
|
||||||
|
resources:
|
||||||
|
- pods
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
|
- list
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
- watch
|
||||||
|
- apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: ${SERVICE_ACCOUNT}
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: ${SERVICE_ACCOUNT}
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: ${SERVICE_ACCOUNT}
|
||||||
|
parameters:
|
||||||
|
- description: The name of the application for labelling all artifacts.
|
||||||
|
displayName: Application Name
|
||||||
|
name: APPLICATION_NAME
|
||||||
|
value: patroni-persistent
|
||||||
|
- description: The name of the patroni-pgsql cluster.
|
||||||
|
displayName: Cluster Name
|
||||||
|
name: PATRONI_CLUSTER_NAME
|
||||||
|
value: patroni-persistent
|
||||||
|
- description: The name of the OpenShift Service exposed for the patroni-persistent-master container.
|
||||||
|
displayName: Master service name.
|
||||||
|
name: PATRONI_MASTER_SERVICE_NAME
|
||||||
|
value: patroni-persistent-master
|
||||||
|
- description: The name of the OpenShift Service exposed for the patroni-persistent-replica containers.
|
||||||
|
displayName: Replica service name.
|
||||||
|
name: PATRONI_REPLICA_SERVICE_NAME
|
||||||
|
value: patroni-persistent-replica
|
||||||
|
- description: Maximum amount of memory the container can use.
|
||||||
|
displayName: Memory Limit
|
||||||
|
name: MEMORY_LIMIT
|
||||||
|
value: 512Mi
|
||||||
|
- description: The OpenShift Namespace where the patroni and postgresql ImageStream resides.
|
||||||
|
displayName: ImageStream Namespace
|
||||||
|
name: NAMESPACE
|
||||||
|
value: openshift
|
||||||
|
- description: Username of the superuser account for initialization.
|
||||||
|
displayName: Superuser Username
|
||||||
|
name: PATRONI_SUPERUSER_USERNAME
|
||||||
|
value: postgres
|
||||||
|
- description: Password of the superuser account for initialization.
|
||||||
|
displayName: Superuser Passsword
|
||||||
|
name: PATRONI_SUPERUSER_PASSWORD
|
||||||
|
value: postgres
|
||||||
|
- description: Username of the replication account for initialization.
|
||||||
|
displayName: Replication Username
|
||||||
|
name: PATRONI_REPLICATION_USERNAME
|
||||||
|
value: postgres
|
||||||
|
- description: Password of the replication account for initialization.
|
||||||
|
displayName: Repication Passsword
|
||||||
|
name: PATRONI_REPLICATION_PASSWORD
|
||||||
|
value: postgres
|
||||||
|
- description: Service account name used for pods and rolebindings to form a cluster in the project.
|
||||||
|
displayName: Service Account
|
||||||
|
name: SERVICE_ACCOUNT
|
||||||
|
value: patroni-persistent
|
||||||
|
- description: The size of the persistent volume to create.
|
||||||
|
displayName: Persistent Volume Size
|
||||||
|
name: PVC_SIZE
|
||||||
|
value: 5Gi
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
stages {
|
||||||
|
stage ('Deploy test pod'){
|
||||||
|
when {
|
||||||
|
expression {
|
||||||
|
openshift.withCluster() {
|
||||||
|
openshift.withProject() {
|
||||||
|
return !openshift.selector( "dc", "pgbench" ).exists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
openshift.withCluster() {
|
||||||
|
openshift.withProject() {
|
||||||
|
def pgbench = openshift.newApp( "https://github.com/stewartshea/docker-pgbench/", "--name=pgbench", "-e PGPASSWORD=postgres", "-e PGUSER=postgres", "-e PGHOST=patroni-persistent-master", "-e PGDATABASE=postgres", "-e TEST_CLIENT_COUNT=20", "-e TEST_DURATION=120" )
|
||||||
|
def pgbenchdc = openshift.selector( "dc", "pgbench" )
|
||||||
|
timeout(5) {
|
||||||
|
pgbenchdc.rollout().status()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage ('Run benchmark Test'){
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
oc exec $(oc get pods -l app=pgbench | grep Running | awk '{print $1}') ./test.sh
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage ('Clean up pgtest pod'){
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
oc delete all -l app=pgbench
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Jenkins Test
|
||||||
|
This pipeline test will create a separate deployment config for a pgbench pod and execute a test against the patroni cluster. This is a sample and should be customized.
|
||||||
@@ -14,6 +14,7 @@ spec:
|
|||||||
application: patroni
|
application: patroni
|
||||||
cluster-name: *cluster_name
|
cluster-name: *cluster_name
|
||||||
spec:
|
spec:
|
||||||
|
serviceAccountName: patronidemo
|
||||||
containers:
|
containers:
|
||||||
- name: *cluster_name
|
- name: *cluster_name
|
||||||
image: patroni # docker build -t patroni .
|
image: patroni # docker build -t patroni .
|
||||||
@@ -35,6 +36,8 @@ spec:
|
|||||||
valueFrom:
|
valueFrom:
|
||||||
fieldRef:
|
fieldRef:
|
||||||
fieldPath: metadata.namespace
|
fieldPath: metadata.namespace
|
||||||
|
- name: PATRONI_KUBERNETES_USE_ENDPOINTS
|
||||||
|
value: 'true'
|
||||||
- name: PATRONI_KUBERNETES_LABELS
|
- name: PATRONI_KUBERNETES_LABELS
|
||||||
value: '{application: patroni, cluster-name: patronidemo}'
|
value: '{application: patroni, cluster-name: patronidemo}'
|
||||||
- name: PATRONI_SUPERUSER_USERNAME
|
- name: PATRONI_SUPERUSER_USERNAME
|
||||||
@@ -120,3 +123,66 @@ type: Opaque
|
|||||||
data:
|
data:
|
||||||
superuser-password: emFsYW5kbw==
|
superuser-password: emFsYW5kbw==
|
||||||
replication-password: cmVwLXBhc3M=
|
replication-password: cmVwLXBhc3M=
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: patronidemo
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: patronidemo
|
||||||
|
rules:
|
||||||
|
- apiGroups:
|
||||||
|
- ""
|
||||||
|
resources:
|
||||||
|
- configmaps
|
||||||
|
verbs:
|
||||||
|
- create
|
||||||
|
- get
|
||||||
|
- list
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
- watch
|
||||||
|
# delete is required only for 'patronictl remove'
|
||||||
|
- delete
|
||||||
|
- apiGroups:
|
||||||
|
- ""
|
||||||
|
resources:
|
||||||
|
- endpoints
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
# the following three privileges are necessary only when using endpoints
|
||||||
|
- create
|
||||||
|
- list
|
||||||
|
- watch
|
||||||
|
# delete is required only for for 'patronictl remove'
|
||||||
|
- delete
|
||||||
|
- apiGroups:
|
||||||
|
- ""
|
||||||
|
resources:
|
||||||
|
- pods
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
|
- list
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
- watch
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: patronidemo
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: patronidemo
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: patronidemo
|
||||||
|
|||||||
+17
-7
@@ -125,18 +125,24 @@ class Patroni(object):
|
|||||||
def setup_signal_handlers(self):
|
def setup_signal_handlers(self):
|
||||||
self._received_sighup = False
|
self._received_sighup = False
|
||||||
self._received_sigterm = False
|
self._received_sigterm = False
|
||||||
signal.signal(signal.SIGHUP, self.sighup_handler)
|
if os.name != 'nt':
|
||||||
|
signal.signal(signal.SIGHUP, self.sighup_handler)
|
||||||
signal.signal(signal.SIGTERM, self.sigterm_handler)
|
signal.signal(signal.SIGTERM, self.sigterm_handler)
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
self.api.shutdown()
|
try:
|
||||||
|
self.api.shutdown()
|
||||||
|
except Exception:
|
||||||
|
logger.exception('Exception during RestApi.shutdown')
|
||||||
self.ha.shutdown()
|
self.ha.shutdown()
|
||||||
|
|
||||||
|
|
||||||
def patroni_main():
|
def patroni_main():
|
||||||
logformat = os.environ.get('PATRONI_LOGFORMAT', '%(asctime)s %(levelname)s: %(message)s')
|
logformat = os.environ.get('PATRONI_LOGFORMAT', '%(asctime)s %(levelname)s: %(message)s')
|
||||||
logging.basicConfig(format=logformat, level=logging.INFO)
|
loglevel = os.environ.get('PATRONI_LOGLEVEL', 'INFO')
|
||||||
logging.getLogger('requests').setLevel(logging.WARNING)
|
requests_loglevel = os.environ.get('PATRONI_REQUESTS_LOGLEVEL', 'WARNING')
|
||||||
|
logging.basicConfig(format=logformat, level=loglevel)
|
||||||
|
logging.getLogger('requests').setLevel(requests_loglevel)
|
||||||
|
|
||||||
patroni = Patroni()
|
patroni = Patroni()
|
||||||
try:
|
try:
|
||||||
@@ -149,6 +155,8 @@ def patroni_main():
|
|||||||
|
|
||||||
def pg_ctl_start(args):
|
def pg_ctl_start(args):
|
||||||
import subprocess
|
import subprocess
|
||||||
|
if os.name != 'nt':
|
||||||
|
os.setsid()
|
||||||
postmaster = subprocess.Popen(args)
|
postmaster = subprocess.Popen(args)
|
||||||
print(postmaster.pid)
|
print(postmaster.pid)
|
||||||
|
|
||||||
@@ -183,7 +191,7 @@ def main():
|
|||||||
if ret == (0, 0):
|
if ret == (0, 0):
|
||||||
break
|
break
|
||||||
elif ret[0] != pid:
|
elif ret[0] != pid:
|
||||||
logging.info('Reaped pid=%s, exit status=%s', *ret)
|
logger.info('Reaped pid=%s, exit status=%s', *ret)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -192,11 +200,13 @@ def main():
|
|||||||
os.kill(pid, signo)
|
os.kill(pid, signo)
|
||||||
|
|
||||||
signal.signal(signal.SIGCHLD, sigchld_handler)
|
signal.signal(signal.SIGCHLD, sigchld_handler)
|
||||||
signal.signal(signal.SIGHUP, passtochild)
|
if os.name != 'nt':
|
||||||
|
signal.signal(signal.SIGHUP, passtochild)
|
||||||
|
signal.signal(signal.SIGQUIT, passtochild)
|
||||||
signal.signal(signal.SIGINT, passtochild)
|
signal.signal(signal.SIGINT, passtochild)
|
||||||
signal.signal(signal.SIGUSR1, passtochild)
|
signal.signal(signal.SIGUSR1, passtochild)
|
||||||
signal.signal(signal.SIGUSR2, passtochild)
|
signal.signal(signal.SIGUSR2, passtochild)
|
||||||
signal.signal(signal.SIGQUIT, passtochild)
|
signal.signal(signal.SIGABRT, passtochild)
|
||||||
signal.signal(signal.SIGTERM, passtochild)
|
signal.signal(signal.SIGTERM, passtochild)
|
||||||
|
|
||||||
patroni = call_self(sys.argv[1:])
|
patroni = call_self(sys.argv[1:])
|
||||||
|
|||||||
+74
-63
@@ -1,14 +1,15 @@
|
|||||||
import base64
|
import base64
|
||||||
import fcntl
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import time
|
import time
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
import datetime
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
from patroni.postgresql import PostgresConnectionException, PostgresException, Postgresql
|
from patroni.postgresql import PostgresConnectionException, PostgresException, Postgresql
|
||||||
from patroni.utils import deep_compare, parse_bool, patch_config, Retry, RetryFailedError, parse_int, tzutc
|
from patroni.utils import deep_compare, parse_bool, patch_config, Retry, \
|
||||||
|
RetryFailedError, parse_int, split_host_port, tzutc
|
||||||
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
||||||
from six.moves.socketserver import ThreadingMixIn
|
from six.moves.socketserver import ThreadingMixIn
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
@@ -83,35 +84,26 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
patroni = self.server.patroni
|
patroni = self.server.patroni
|
||||||
cluster = patroni.dcs.cluster
|
cluster = patroni.dcs.cluster
|
||||||
|
|
||||||
def is_synchronous():
|
replica_status_code = 200 if not patroni.noloadbalance and response.get('role') == 'replica' else 503
|
||||||
return (cluster.is_synchronous_mode() and cluster.sync
|
status_code = 503
|
||||||
and cluster.sync.sync_standby == patroni.postgresql.name)
|
|
||||||
|
|
||||||
def is_balanceable_replica():
|
if patroni.ha.is_standby_cluster() and ('standby_leader' in path or 'standby-leader' in path):
|
||||||
return response.get('role') == 'replica' and not patroni.noloadbalance
|
status_code = 200 if patroni.ha.is_leader() else 503
|
||||||
|
elif 'master' in path or 'leader' in path or 'primary' in path:
|
||||||
if cluster: # dcs available
|
# Round-robing across all masters in pause mode if DCS is not accessible
|
||||||
if cluster.leader and cluster.leader.name == patroni.postgresql.name: # is_leader
|
if not cluster and patroni.ha.is_paused():
|
||||||
status_code = 200 if 'master' in path else 503
|
status_code = 200 if response['role'] == 'master' else 503
|
||||||
elif 'role' not in response:
|
|
||||||
status_code = 503
|
|
||||||
elif response['role'] == 'master': # running as master but without leader lock!!!!
|
|
||||||
status_code = 503
|
|
||||||
elif path in ('/sync', '/synchronous'):
|
|
||||||
status_code = 200 if is_balanceable_replica() and is_synchronous() else 503
|
|
||||||
elif path in ('/async', '/asynchronous'):
|
|
||||||
status_code = 200 if is_balanceable_replica() and not is_synchronous() else 503
|
|
||||||
elif response['role'] in path: # response['role'] != 'master'
|
|
||||||
status_code = 503 if patroni.noloadbalance else 200
|
|
||||||
else:
|
else:
|
||||||
status_code = 503
|
status_code = 200 if patroni.ha.is_leader() else 503
|
||||||
elif 'role' in response and response['role'] in path:
|
elif 'replica' in path:
|
||||||
status_code = 503 if response['role'] != 'master' and patroni.noloadbalance else 200
|
status_code = replica_status_code
|
||||||
elif patroni.ha.restart_scheduled() and patroni.postgresql.role == 'master' and 'master' in path:
|
elif cluster: # dcs is available
|
||||||
# exceptional case for master node when the postgres is being restarted via API
|
is_synchronous = cluster.is_synchronous_mode() and cluster.sync \
|
||||||
status_code = 200
|
and cluster.sync.sync_standby == patroni.postgresql.name
|
||||||
else:
|
if path in ('/sync', '/synchronous') and is_synchronous:
|
||||||
status_code = 503
|
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
|
if write_status_code_only: # when haproxy sends OPTIONS request it reads only status code and nothing more
|
||||||
message = self.responses[status_code][0]
|
message = self.responses[status_code][0]
|
||||||
@@ -295,22 +287,23 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
status_code = 503
|
status_code = 503
|
||||||
self._write_response(status_code, data)
|
self._write_response(status_code, data)
|
||||||
|
|
||||||
def poll_failover_result(self, leader, candidate):
|
def poll_failover_result(self, leader, candidate, action):
|
||||||
timeout = max(10, self.server.patroni.dcs.loop_wait)
|
timeout = max(10, self.server.patroni.dcs.loop_wait)
|
||||||
for _ in range(0, timeout*2):
|
for _ in range(0, timeout*2):
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
try:
|
try:
|
||||||
cluster = self.server.patroni.dcs.get_cluster()
|
cluster = self.server.patroni.dcs.get_cluster()
|
||||||
if cluster.leader and cluster.leader.name != leader:
|
if not cluster.is_unlocked() and cluster.leader.name != leader:
|
||||||
if not candidate or candidate == cluster.leader.name:
|
if not candidate or candidate == cluster.leader.name:
|
||||||
return 200, 'Successfully failed over to "{0}"'.format(cluster.leader.name)
|
return 200, 'Successfully {0}ed over to "{1}"'.format(action[:-4], cluster.leader.name)
|
||||||
else:
|
else:
|
||||||
return 200, 'Failed over to "{0}" instead of "{1}"'.format(cluster.leader.name, candidate)
|
return 200, '{0}ed over to "{1}" instead of "{2}"'.format(action[:-4].title(),
|
||||||
|
cluster.leader.name, candidate)
|
||||||
if not cluster.failover:
|
if not cluster.failover:
|
||||||
return 503, 'Failover failed'
|
return 503, action.title() + ' failed'
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug('Exception occured during polling failover result: %s', e)
|
logger.debug('Exception occured during polling %s result: %s', action, e)
|
||||||
return 503, 'Failover status unknown'
|
return 503, action.title() + ' status unknown'
|
||||||
|
|
||||||
def is_failover_possible(self, cluster, leader, candidate, action):
|
def is_failover_possible(self, cluster, leader, candidate, action):
|
||||||
if leader and (not cluster.leader or cluster.leader.name != leader):
|
if leader and (not cluster.leader or cluster.leader.name != leader):
|
||||||
@@ -377,7 +370,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
data = action.title() + ' scheduled'
|
data = action.title() + ' scheduled'
|
||||||
status_code = 202
|
status_code = 202
|
||||||
else:
|
else:
|
||||||
status_code, data = self.poll_failover_result(cluster.leader and cluster.leader.name, candidate)
|
status_code, data = self.poll_failover_result(cluster.leader and cluster.leader.name,
|
||||||
|
candidate, action)
|
||||||
else:
|
else:
|
||||||
data = 'failed to write {0} key into DCS'.format(action)
|
data = 'failed to write {0} key into DCS'.format(action)
|
||||||
status_code = 503
|
status_code = 503
|
||||||
@@ -412,21 +406,26 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def get_postgresql_status(self, retry=False):
|
def get_postgresql_status(self, retry=False):
|
||||||
try:
|
try:
|
||||||
|
cluster = self.server.patroni.dcs.cluster
|
||||||
|
|
||||||
if self.server.patroni.postgresql.state not in ('running', 'restarting', 'starting'):
|
if self.server.patroni.postgresql.state not in ('running', 'restarting', 'starting'):
|
||||||
raise RetryFailedError('')
|
raise RetryFailedError('')
|
||||||
stmt = ("WITH replication_info AS ("
|
stmt = ("WITH replication_info AS ("
|
||||||
"SELECT usename, application_name, client_addr, state, sync_state, sync_priority"
|
"SELECT usename, application_name, client_addr, state, sync_state, sync_priority"
|
||||||
" FROM pg_stat_replication) SELECT"
|
" FROM pg_catalog.pg_stat_replication) SELECT"
|
||||||
" to_char(pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),"
|
" pg_catalog.to_char(pg_catalog.pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),"
|
||||||
" CASE WHEN pg_is_in_recovery() THEN 0"
|
" CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0"
|
||||||
" ELSE ('x' || SUBSTR(pg_{0}file_name(pg_current_{0}_{1}()), 1, 8))::bit(32)::int END,"
|
" ELSE ('x' || pg_catalog.substr(pg_catalog.pg_{0}file_name("
|
||||||
" CASE WHEN pg_is_in_recovery() THEN 0"
|
"pg_catalog.pg_current_{0}_{1}()), 1, 8))::bit(32)::int END,"
|
||||||
" ELSE pg_{0}_{1}_diff(pg_current_{0}_{1}(), '0/0')::bigint END,"
|
" CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0"
|
||||||
" pg_{0}_{1}_diff(COALESCE(pg_last_{0}_receive_{1}(), pg_last_{0}_replay_{1}()), '0/0')::bigint,"
|
" ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), '0/0')::bigint END,"
|
||||||
" pg_{0}_{1}_diff(pg_last_{0}_replay_{1}(), '0/0')::bigint,"
|
" pg_catalog.pg_{0}_{1}_diff(COALESCE(pg_catalog.pg_last_{0}_receive_{1}(),"
|
||||||
" to_char(pg_last_xact_replay_timestamp(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),"
|
" pg_catalog.pg_last_{0}_replay_{1}()), '0/0')::bigint,"
|
||||||
" pg_is_in_recovery() AND pg_is_{0}_replay_paused(),"
|
" pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), '0/0')::bigint,"
|
||||||
" (SELECT array_to_json(array_agg(row_to_json(ri))) FROM replication_info ri)")
|
" pg_catalog.to_char(pg_catalog.pg_last_xact_replay_timestamp(), 'YYYY-MM-DD HH24:MI:SS.MS TZ'),"
|
||||||
|
" pg_catalog.pg_is_in_recovery() AND pg_catalog.pg_is_{0}_replay_paused(), "
|
||||||
|
"(SELECT pg_catalog.array_to_json(pg_catalog.array_agg("
|
||||||
|
"pg_catalog.row_to_json(ri))) FROM replication_info ri)")
|
||||||
|
|
||||||
row = self.query(stmt.format(self.server.patroni.postgresql.wal_name,
|
row = self.query(stmt.format(self.server.patroni.postgresql.wal_name,
|
||||||
self.server.patroni.postgresql.lsn_name), retry=retry)[0]
|
self.server.patroni.postgresql.lsn_name), retry=retry)[0]
|
||||||
@@ -436,6 +435,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
'postmaster_start_time': row[0],
|
'postmaster_start_time': row[0],
|
||||||
'role': 'replica' if row[1] == 0 else 'master',
|
'role': 'replica' if row[1] == 0 else 'master',
|
||||||
'server_version': self.server.patroni.postgresql.server_version,
|
'server_version': self.server.patroni.postgresql.server_version,
|
||||||
|
'cluster_unlocked': bool(not cluster or cluster.is_unlocked()),
|
||||||
'xlog': ({
|
'xlog': ({
|
||||||
'received_location': row[3],
|
'received_location': row[3],
|
||||||
'replayed_location': row[4],
|
'replayed_location': row[4],
|
||||||
@@ -448,7 +448,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
if row[1] > 0:
|
if row[1] > 0:
|
||||||
result['timeline'] = row[1]
|
result['timeline'] = row[1]
|
||||||
else:
|
else:
|
||||||
cluster = self.server.patroni.dcs.cluster
|
|
||||||
leader_timeline = None if not cluster or cluster.is_unlocked() else cluster.leader.timeline
|
leader_timeline = None if not cluster or cluster.is_unlocked() else cluster.leader.timeline
|
||||||
result['timeline'] = self.server.patroni.postgresql.replica_cached_timeline(leader_timeline)
|
result['timeline'] = self.server.patroni.postgresql.replica_cached_timeline(leader_timeline)
|
||||||
|
|
||||||
@@ -471,6 +470,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
|||||||
|
|
||||||
def __init__(self, patroni, config):
|
def __init__(self, patroni, config):
|
||||||
self.patroni = patroni
|
self.patroni = patroni
|
||||||
|
self.__listen = None
|
||||||
self.__initialize(config)
|
self.__initialize(config)
|
||||||
self.__set_config_parameters(config)
|
self.__set_config_parameters(config)
|
||||||
self.daemon = True
|
self.daemon = True
|
||||||
@@ -488,8 +488,10 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _set_fd_cloexec(fd):
|
def _set_fd_cloexec(fd):
|
||||||
flags = fcntl.fcntl(fd, fcntl.F_GETFD)
|
if os.name != 'nt':
|
||||||
fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC)
|
import fcntl
|
||||||
|
flags = fcntl.fcntl(fd, fcntl.F_GETFD)
|
||||||
|
fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC)
|
||||||
|
|
||||||
def check_basic_auth_key(self, key):
|
def check_basic_auth_key(self, key):
|
||||||
return self.__auth_key == key
|
return self.__auth_key == key
|
||||||
@@ -505,18 +507,25 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
|||||||
def __get_ssl_options(config):
|
def __get_ssl_options(config):
|
||||||
return {option: config[option] for option in ['certfile', 'keyfile'] if option in config}
|
return {option: config[option] for option in ['certfile', 'keyfile'] if option in config}
|
||||||
|
|
||||||
def __set_connection_string(self, connect_address):
|
|
||||||
self.connection_string = '{0}://{1}/patroni'.format(self.__protocol, connect_address or self.__listen)
|
|
||||||
|
|
||||||
def __set_config_parameters(self, config):
|
def __set_config_parameters(self, config):
|
||||||
self.__auth_key = base64.b64encode(config['auth'].encode('utf-8')).decode('utf-8') if 'auth' in config else None
|
self.__auth_key = base64.b64encode(config['auth'].encode('utf-8')).decode('utf-8') if 'auth' in config else None
|
||||||
self.__set_connection_string(config.get('connect_address'))
|
self.connection_string = '{0}://{1}/patroni'.format(self.__protocol,
|
||||||
|
config.get('connect_address') or self.__listen)
|
||||||
|
|
||||||
def __initialize(self, config):
|
def __initialize(self, config):
|
||||||
self.__ssl_options = self.__get_ssl_options(config)
|
try:
|
||||||
|
host, port = split_host_port(config['listen'], None)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError('Invalid "restapi" config: expected <HOST>:<PORT> for "listen", but got "{0}"'
|
||||||
|
.format(config['listen']))
|
||||||
|
|
||||||
|
if self.__listen is not None: # changing config in runtime
|
||||||
|
self.shutdown()
|
||||||
|
|
||||||
self.__listen = config['listen']
|
self.__listen = config['listen']
|
||||||
host, port = config['listen'].rsplit(':', 1)
|
self.__ssl_options = self.__get_ssl_options(config)
|
||||||
HTTPServer.__init__(self, (host, int(port)), RestApiHandler)
|
|
||||||
|
HTTPServer.__init__(self, (host, port), RestApiHandler)
|
||||||
Thread.__init__(self, target=self.serve_forever)
|
Thread.__init__(self, target=self.serve_forever)
|
||||||
self._set_fd_cloexec(self.socket)
|
self._set_fd_cloexec(self.socket)
|
||||||
|
|
||||||
@@ -528,11 +537,13 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
|
|||||||
import ssl
|
import ssl
|
||||||
self.socket = ssl.wrap_socket(self.socket, server_side=True, **self.__ssl_options)
|
self.socket = ssl.wrap_socket(self.socket, server_side=True, **self.__ssl_options)
|
||||||
self.__protocol = 'https'
|
self.__protocol = 'https'
|
||||||
self.__set_connection_string(config.get('connect_address'))
|
return True
|
||||||
|
|
||||||
def reload_config(self, config):
|
def reload_config(self, config):
|
||||||
self.__set_config_parameters(config)
|
if 'listen' not in config: # changing config in runtime
|
||||||
if self.__listen != config['listen'] or self.__ssl_options != self.__get_ssl_options(config):
|
raise ValueError('Can not find "restapi.listen" config')
|
||||||
self.shutdown()
|
|
||||||
self.__initialize(config)
|
elif (self.__listen != config['listen'] or self.__ssl_options != self.__get_ssl_options(config)) \
|
||||||
|
and self.__initialize(config):
|
||||||
self.start()
|
self.start()
|
||||||
|
self.__set_config_parameters(config)
|
||||||
|
|||||||
+36
-5
@@ -1,6 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import six
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import yaml
|
import yaml
|
||||||
@@ -9,7 +11,8 @@ from collections import defaultdict
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from patroni.dcs import ClusterConfig
|
from patroni.dcs import ClusterConfig
|
||||||
from patroni.postgresql import Postgresql
|
from patroni.postgresql import Postgresql
|
||||||
from patroni.utils import deep_compare, parse_int, patch_config
|
from patroni.utils import deep_compare, parse_bool, parse_int, patch_config
|
||||||
|
from requests.structures import CaseInsensitiveDict
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,10 +47,19 @@ class Config(object):
|
|||||||
'master_start_timeout': 300,
|
'master_start_timeout': 300,
|
||||||
'synchronous_mode': False,
|
'synchronous_mode': False,
|
||||||
'synchronous_mode_strict': False,
|
'synchronous_mode_strict': False,
|
||||||
|
'standby_cluster': {
|
||||||
|
'create_replica_methods': '',
|
||||||
|
'host': '',
|
||||||
|
'port': '',
|
||||||
|
'primary_slot_name': '',
|
||||||
|
'restore_command': '',
|
||||||
|
'archive_cleanup_command': '',
|
||||||
|
'recovery_min_apply_delay': ''
|
||||||
|
},
|
||||||
'postgresql': {
|
'postgresql': {
|
||||||
'bin_dir': '',
|
'bin_dir': '',
|
||||||
'use_slots': True,
|
'use_slots': True,
|
||||||
'parameters': {p: v[0] for p, v in Postgresql.CMDLINE_OPTIONS.items()}
|
'parameters': CaseInsensitiveDict({p: v[0] for p, v in Postgresql.CMDLINE_OPTIONS.items()})
|
||||||
},
|
},
|
||||||
'watchdog': {
|
'watchdog': {
|
||||||
'mode': 'automatic',
|
'mode': 'automatic',
|
||||||
@@ -87,6 +99,9 @@ class Config(object):
|
|||||||
def dynamic_configuration(self):
|
def dynamic_configuration(self):
|
||||||
return deepcopy(self._dynamic_configuration)
|
return deepcopy(self._dynamic_configuration)
|
||||||
|
|
||||||
|
def check_mode(self, mode):
|
||||||
|
return bool(parse_bool(self._dynamic_configuration.get(mode)))
|
||||||
|
|
||||||
def _load_config_file(self):
|
def _load_config_file(self):
|
||||||
"""Loads config.yaml from filesystem and applies some values which were set via ENV"""
|
"""Loads config.yaml from filesystem and applies some values which were set via ENV"""
|
||||||
with open(self._config_file) as f:
|
with open(self._config_file) as f:
|
||||||
@@ -110,7 +125,7 @@ class Config(object):
|
|||||||
with os.fdopen(fd, 'w') as f:
|
with os.fdopen(fd, 'w') as f:
|
||||||
fd = None
|
fd = None
|
||||||
json.dump(self.dynamic_configuration, f)
|
json.dump(self.dynamic_configuration, f)
|
||||||
tmpfile = os.rename(tmpfile, self._cache_file)
|
tmpfile = shutil.move(tmpfile, self._cache_file)
|
||||||
self._cache_needs_saving = False
|
self._cache_needs_saving = False
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('Exception when saving file: %s', self._cache_file)
|
logger.exception('Exception when saving file: %s', self._cache_file)
|
||||||
@@ -154,6 +169,8 @@ class Config(object):
|
|||||||
self._local_configuration = configuration
|
self._local_configuration = configuration
|
||||||
self.__effective_configuration = new_configuration
|
self.__effective_configuration = new_configuration
|
||||||
return True
|
return True
|
||||||
|
else:
|
||||||
|
logger.info('No configuration items changed, nothing to reload.')
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('Exception when reloading local configuration from %s', self.config_file)
|
logger.exception('Exception when reloading local configuration from %s', self.config_file)
|
||||||
if dry_run:
|
if dry_run:
|
||||||
@@ -177,6 +194,13 @@ class Config(object):
|
|||||||
config['postgresql'][name].update(self._process_postgresql_parameters(value))
|
config['postgresql'][name].update(self._process_postgresql_parameters(value))
|
||||||
elif name not in ('connect_address', 'listen', 'data_dir', 'pgpass', 'authentication'):
|
elif name not in ('connect_address', 'listen', 'data_dir', 'pgpass', 'authentication'):
|
||||||
config['postgresql'][name] = deepcopy(value)
|
config['postgresql'][name] = deepcopy(value)
|
||||||
|
elif name == 'standby_cluster':
|
||||||
|
allowed_keys = self.__DEFAULT_CONFIG['standby_cluster'].keys()
|
||||||
|
expected = {
|
||||||
|
k: v for k, v in (value or {}).items()
|
||||||
|
if (k in allowed_keys and isinstance(v, six.string_types))
|
||||||
|
}
|
||||||
|
config['standby_cluster'].update(expected)
|
||||||
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overriden from DCS
|
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overriden from DCS
|
||||||
if name in ('synchronous_mode', 'synchronous_mode_strict'):
|
if name in ('synchronous_mode', 'synchronous_mode_strict'):
|
||||||
config[name] = value
|
config[name] = value
|
||||||
@@ -311,8 +335,15 @@ class Config(object):
|
|||||||
if 'name' not in config and 'name' in pg_config:
|
if 'name' not in config and 'name' in pg_config:
|
||||||
config['name'] = pg_config['name']
|
config['name'] = pg_config['name']
|
||||||
|
|
||||||
pg_config.update({p: config[p] for p in ('name', 'scope', 'retry_timeout',
|
updated_fields = (
|
||||||
'synchronous_mode', 'maximum_lag_on_failover') if p in config})
|
'name',
|
||||||
|
'scope',
|
||||||
|
'retry_timeout',
|
||||||
|
'synchronous_mode',
|
||||||
|
'maximum_lag_on_failover'
|
||||||
|
)
|
||||||
|
|
||||||
|
pg_config.update({p: config[p] for p in updated_fields if p in config})
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|||||||
+82
-24
@@ -103,15 +103,20 @@ option_watch = click.option('-W', is_flag=True, help='Auto update the screen eve
|
|||||||
option_force = click.option('--force', is_flag=True, help='Do not ask for confirmation at any point')
|
option_force = click.option('--force', is_flag=True, help='Do not ask for confirmation at any point')
|
||||||
arg_cluster_name = click.argument('cluster_name', required=False,
|
arg_cluster_name = click.argument('cluster_name', required=False,
|
||||||
default=lambda: click.get_current_context().obj.get('scope'))
|
default=lambda: click.get_current_context().obj.get('scope'))
|
||||||
|
option_insecure = click.option('-k', '--insecure', is_flag=True, help='Allow connections to SSL sites without certs')
|
||||||
|
|
||||||
|
|
||||||
@click.group()
|
@click.group()
|
||||||
@click.option('--config-file', '-c', help='Configuration file', default=CONFIG_FILE_PATH)
|
@click.option('--config-file', '-c', help='Configuration file', default=CONFIG_FILE_PATH)
|
||||||
@click.option('--dcs', '-d', help='Use this DCS', envvar='DCS')
|
@click.option('--dcs', '-d', help='Use this DCS', envvar='DCS')
|
||||||
|
@option_insecure
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def ctl(ctx, config_file, dcs):
|
def ctl(ctx, config_file, dcs, insecure):
|
||||||
logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=os.environ.get('LOGLEVEL', 'WARNING'))
|
logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=os.environ.get('LOGLEVEL', 'WARNING'))
|
||||||
|
logging.captureWarnings(True) # Capture eventual SSL warning
|
||||||
ctx.obj = load_config(config_file, dcs)
|
ctx.obj = load_config(config_file, dcs)
|
||||||
|
# backward compatibility for configuration file where ctl section is not define
|
||||||
|
ctx.obj.setdefault('ctl', {})['insecure'] = ctx.obj.get('ctl', {}).get('insecure') or insecure
|
||||||
|
|
||||||
|
|
||||||
def get_dcs(config, scope):
|
def get_dcs(config, scope):
|
||||||
@@ -129,6 +134,7 @@ def auth_header(config):
|
|||||||
|
|
||||||
|
|
||||||
def request_patroni(member, request_type, endpoint, content=None, headers=None):
|
def request_patroni(member, request_type, endpoint, content=None, headers=None):
|
||||||
|
ctx = click.get_current_context() # the current click context
|
||||||
headers = headers or {}
|
headers = headers or {}
|
||||||
url_parts = urlparse(member.api_url)
|
url_parts = urlparse(member.api_url)
|
||||||
logging.debug(url_parts)
|
logging.debug(url_parts)
|
||||||
@@ -137,8 +143,21 @@ def request_patroni(member, request_type, endpoint, content=None, headers=None):
|
|||||||
|
|
||||||
url = '{0}://{1}/{2}'.format(url_parts.scheme, url_parts.netloc, endpoint)
|
url = '{0}://{1}/{2}'.format(url_parts.scheme, url_parts.netloc, endpoint)
|
||||||
|
|
||||||
|
insecure = ctx.obj.get('ctl', {}).get('insecure', False)
|
||||||
|
# Get certfile if any from several configuration namespace
|
||||||
|
cert = ctx.obj.get('ctl', {}).get('cacert') or \
|
||||||
|
ctx.obj.get('restapi', {}).get('cacert') or \
|
||||||
|
ctx.obj.get('restapi', {}).get('certfile')
|
||||||
|
# In the case we specificaly disable SSL cert verification we don't want to have the warning
|
||||||
|
if insecure:
|
||||||
|
verify = False
|
||||||
|
elif cert:
|
||||||
|
verify = cert
|
||||||
|
else:
|
||||||
|
verify = True
|
||||||
return getattr(requests, request_type)(url, headers=headers,
|
return getattr(requests, request_type)(url, headers=headers,
|
||||||
data=json.dumps(content) if content else None, timeout=60)
|
data=json.dumps(content) if content else None, timeout=60,
|
||||||
|
verify=verify)
|
||||||
|
|
||||||
|
|
||||||
def print_output(columns, rows=None, alignment=None, fmt='pretty', header=True, delimiter='\t'):
|
def print_output(columns, rows=None, alignment=None, fmt='pretty', header=True, delimiter='\t'):
|
||||||
@@ -233,7 +252,7 @@ def get_cursor(cluster, connect_parameters, role='master', member=None):
|
|||||||
if role == 'any':
|
if role == 'any':
|
||||||
return cursor
|
return cursor
|
||||||
|
|
||||||
cursor.execute('SELECT pg_is_in_recovery()')
|
cursor.execute('SELECT pg_catalog.pg_is_in_recovery()')
|
||||||
in_recovery = cursor.fetchone()[0]
|
in_recovery = cursor.fetchone()[0]
|
||||||
|
|
||||||
if in_recovery and role == 'replica' or not in_recovery and role == 'master':
|
if in_recovery and role == 'replica' or not in_recovery and role == 'master':
|
||||||
@@ -373,7 +392,7 @@ def query_member(cluster, cursor, member, role, command, connect_parameters):
|
|||||||
logging.debug(message)
|
logging.debug(message)
|
||||||
return [[timestamp(0), message]], None
|
return [[timestamp(0), message]], None
|
||||||
|
|
||||||
cursor.execute('SELECT pg_is_in_recovery()')
|
cursor.execute('SELECT pg_catalog.pg_is_in_recovery()')
|
||||||
in_recovery = cursor.fetchone()[0]
|
in_recovery = cursor.fetchone()[0]
|
||||||
|
|
||||||
if in_recovery and role == 'master' or not in_recovery and role == 'replica':
|
if in_recovery and role == 'master' or not in_recovery and role == 'replica':
|
||||||
@@ -445,6 +464,33 @@ def parse_scheduled(scheduled):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@ctl.command('reload', help='Reload cluster member configuration')
|
||||||
|
@click.argument('cluster_name')
|
||||||
|
@click.argument('member_names', nargs=-1)
|
||||||
|
@click.option('--role', '-r', help='Reload only members with this role', default='any',
|
||||||
|
type=click.Choice(['master', 'replica', 'any']))
|
||||||
|
@option_force
|
||||||
|
@click.pass_obj
|
||||||
|
def reload(obj, cluster_name, member_names, force, role):
|
||||||
|
cluster = get_dcs(obj, cluster_name).get_cluster()
|
||||||
|
|
||||||
|
members = get_members(cluster, cluster_name, member_names, role, force, 'reload')
|
||||||
|
|
||||||
|
content = {}
|
||||||
|
for member in members:
|
||||||
|
r = request_patroni(member, 'post', 'reload', content, auth_header(obj))
|
||||||
|
if r.status_code == 200:
|
||||||
|
click.echo('No changes to apply on member {0}'.format(member.name))
|
||||||
|
elif r.status_code == 202:
|
||||||
|
click.echo('Reload request received for member {0} and will be processed within {1} seconds'.format(
|
||||||
|
member.name, cluster.config.data.get('loop_wait'))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
click.echo('Failed: reload for member {0}, status code={1}, ({2})'.format(
|
||||||
|
member.name, r.status_code, r.text)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@ctl.command('restart', help='Restart cluster member')
|
@ctl.command('restart', help='Restart cluster member')
|
||||||
@click.argument('cluster_name')
|
@click.argument('cluster_name')
|
||||||
@click.argument('member_names', nargs=-1)
|
@click.argument('member_names', nargs=-1)
|
||||||
@@ -546,20 +592,20 @@ def _do_failover_or_switchover(obj, action, cluster_name, master, candidate, for
|
|||||||
dcs = get_dcs(obj, cluster_name)
|
dcs = get_dcs(obj, cluster_name)
|
||||||
cluster = dcs.get_cluster()
|
cluster = dcs.get_cluster()
|
||||||
|
|
||||||
if action == 'switchover':
|
if action == 'switchover' and cluster.leader is None:
|
||||||
if cluster.leader is None:
|
raise PatroniCtlException('This cluster has no master')
|
||||||
raise PatroniCtlException('This cluster has no master')
|
|
||||||
|
|
||||||
if master is None:
|
if master is None:
|
||||||
if force:
|
if force or action == 'failover':
|
||||||
master = cluster.leader.member.name
|
master = cluster.leader and cluster.leader.name
|
||||||
else:
|
else:
|
||||||
master = click.prompt('Master', type=str, default=cluster.leader.member.name)
|
master = click.prompt('Master', type=str, default=cluster.leader.member.name)
|
||||||
|
|
||||||
if master is not None and cluster.leader and cluster.leader.member.name != master:
|
if master is not None and cluster.leader and cluster.leader.member.name != master:
|
||||||
raise PatroniCtlException('Member {0} is not the leader of cluster {1}'.format(master, cluster_name))
|
raise PatroniCtlException('Member {0} is not the leader of cluster {1}'.format(master, cluster_name))
|
||||||
|
|
||||||
candidate_names = [str(m.name) for m in cluster.members if m.name != master]
|
# excluding members with nofailover tag
|
||||||
|
candidate_names = [str(m.name) for m in cluster.members if m.name != master and not m.nofailover]
|
||||||
# We sort the names for consistent output to the client
|
# We sort the names for consistent output to the client
|
||||||
candidate_names.sort()
|
candidate_names.sort()
|
||||||
|
|
||||||
@@ -610,6 +656,11 @@ def _do_failover_or_switchover(obj, action, cluster_name, master, candidate, for
|
|||||||
member = cluster.leader.member if cluster.leader else cluster.get_member(candidate, False)
|
member = cluster.leader.member if cluster.leader else cluster.get_member(candidate, False)
|
||||||
|
|
||||||
r = request_patroni(member, 'post', action, failover_value, auth_header(obj))
|
r = request_patroni(member, 'post', action, failover_value, auth_header(obj))
|
||||||
|
|
||||||
|
# probably old patroni, which doesn't support switchover yet
|
||||||
|
if r.status_code == 501 and action == 'switchover' and 'Server does not support this operation' in r.text:
|
||||||
|
r = request_patroni(member, 'post', 'failover', failover_value, auth_header(obj))
|
||||||
|
|
||||||
if r.status_code in (200, 202):
|
if r.status_code in (200, 202):
|
||||||
logging.debug(r)
|
logging.debug(r)
|
||||||
cluster = dcs.get_cluster()
|
cluster = dcs.get_cluster()
|
||||||
@@ -662,7 +713,8 @@ def output_members(cluster, name, extended=False, fmt='pretty'):
|
|||||||
# Mainly for consistent pretty printing and watching we sort the output
|
# Mainly for consistent pretty printing and watching we sort the output
|
||||||
cluster.members.sort(key=lambda x: x.name)
|
cluster.members.sort(key=lambda x: x.name)
|
||||||
|
|
||||||
extended = extended or any(m.data.get('scheduled_restart') for m in cluster.members)
|
has_scheduled_restarts = any(m.data.get('scheduled_restart') for m in cluster.members)
|
||||||
|
has_pending_restarts = any(m.data.get('pending_restart') for m in cluster.members)
|
||||||
|
|
||||||
for m in cluster.members:
|
for m in cluster.members:
|
||||||
logging.debug(m)
|
logging.debug(m)
|
||||||
@@ -682,7 +734,10 @@ def output_members(cluster, name, extended=False, fmt='pretty'):
|
|||||||
|
|
||||||
row = [name, m.name, m.conn_kwargs()['host'], role, m.data.get('state', ''), lag]
|
row = [name, m.name, m.conn_kwargs()['host'], role, m.data.get('state', ''), lag]
|
||||||
|
|
||||||
if extended:
|
if extended or has_pending_restarts:
|
||||||
|
row.append('*' if m.data.get('pending_restart') else '')
|
||||||
|
|
||||||
|
if extended or has_scheduled_restarts:
|
||||||
value = ''
|
value = ''
|
||||||
scheduled_restart = m.data.get('scheduled_restart')
|
scheduled_restart = m.data.get('scheduled_restart')
|
||||||
if scheduled_restart:
|
if scheduled_restart:
|
||||||
@@ -695,11 +750,13 @@ def output_members(cluster, name, extended=False, fmt='pretty'):
|
|||||||
rows.append(row)
|
rows.append(row)
|
||||||
|
|
||||||
columns = ['Cluster', 'Member', 'Host', 'Role', 'State', 'Lag in MB']
|
columns = ['Cluster', 'Member', 'Host', 'Role', 'State', 'Lag in MB']
|
||||||
alignment = {'Cluster': 'l', 'Member': 'l', 'Host': 'l', 'Lag in MB': 'r'}
|
alignment = {'Lag in MB': 'r'}
|
||||||
|
|
||||||
if extended:
|
if extended or has_pending_restarts:
|
||||||
|
columns.append('Pending restart')
|
||||||
|
|
||||||
|
if extended or has_scheduled_restarts:
|
||||||
columns.append('Scheduled restart')
|
columns.append('Scheduled restart')
|
||||||
alignment['Scheduled restart'] = 'l'
|
|
||||||
|
|
||||||
print_output(columns, rows, alignment, fmt)
|
print_output(columns, rows, alignment, fmt)
|
||||||
|
|
||||||
@@ -708,11 +765,11 @@ def output_members(cluster, name, extended=False, fmt='pretty'):
|
|||||||
service_info.append('Maintenance mode: on')
|
service_info.append('Maintenance mode: on')
|
||||||
|
|
||||||
if cluster.failover and cluster.failover.scheduled_at:
|
if cluster.failover and cluster.failover.scheduled_at:
|
||||||
info = 'Failover scheduled at: ' + cluster.failover.scheduled_at.isoformat()
|
info = 'Switchover scheduled at: ' + cluster.failover.scheduled_at.isoformat()
|
||||||
if cluster.failover.leader:
|
if cluster.failover.leader:
|
||||||
info += '\n from: ' + cluster.failover.leader
|
info += '\n from: ' + cluster.failover.leader
|
||||||
if cluster.failover.candidate:
|
if cluster.failover.candidate:
|
||||||
info += '\n to: ' + cluster.failover.candidate
|
info += '\n to: ' + cluster.failover.candidate
|
||||||
service_info.append(info)
|
service_info.append(info)
|
||||||
|
|
||||||
if service_info:
|
if service_info:
|
||||||
@@ -776,7 +833,7 @@ def touch_member(config, dcs):
|
|||||||
'role': p.role
|
'role': p.role
|
||||||
}
|
}
|
||||||
|
|
||||||
return dcs.touch_member(json.dumps(data, separators=(',', ':')), permanent=True)
|
return dcs.touch_member(data, permanent=True)
|
||||||
|
|
||||||
|
|
||||||
def set_defaults(config, cluster_name):
|
def set_defaults(config, cluster_name):
|
||||||
@@ -865,7 +922,8 @@ def toggle_pause(config, cluster_name, paused, wait):
|
|||||||
for member in members:
|
for member in members:
|
||||||
try:
|
try:
|
||||||
r = request_patroni(member, 'patch', 'config', {'pause': paused or None}, auth_header(config))
|
r = request_patroni(member, 'patch', 'config', {'pause': paused or None}, auth_header(config))
|
||||||
except Exception:
|
except Exception as err:
|
||||||
|
logging.warning(str(err))
|
||||||
logging.warning('Member %s is not accessible', member.name)
|
logging.warning('Member %s is not accessible', member.name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1091,7 +1149,7 @@ def edit_config(obj, cluster_name, force, quiet, kvpairs, pgkvpairs, apply_filen
|
|||||||
|
|
||||||
|
|
||||||
@ctl.command('show-config', help="Show cluster configuration")
|
@ctl.command('show-config', help="Show cluster configuration")
|
||||||
@click.argument('cluster_name')
|
@arg_cluster_name
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def show_config(obj, cluster_name):
|
def show_config(obj, cluster_name):
|
||||||
cluster = get_dcs(obj, cluster_name).get_cluster()
|
cluster = get_dcs(obj, cluster_name).get_cluster()
|
||||||
|
|||||||
+138
-12
@@ -6,18 +6,38 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pkgutil
|
import pkgutil
|
||||||
|
import re
|
||||||
import six
|
import six
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from collections import namedtuple
|
from collections import defaultdict, namedtuple
|
||||||
|
from copy import deepcopy
|
||||||
from patroni.exceptions import PatroniException
|
from patroni.exceptions import PatroniException
|
||||||
|
from patroni.utils import parse_bool
|
||||||
from random import randint
|
from random import randint
|
||||||
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
|
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
|
||||||
from threading import Event, Lock
|
from threading import Event, Lock
|
||||||
|
|
||||||
|
slot_name_re = re.compile('^[a-z0-9_]{1,63}$')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def slot_name_from_member_name(member_name):
|
||||||
|
"""Translate member name to valid PostgreSQL slot name.
|
||||||
|
|
||||||
|
PostgreSQL replication slot names must be valid PostgreSQL names. This function maps the wider space of
|
||||||
|
member names to valid PostgreSQL names. Names are lowercased, dashes and periods common in hostnames
|
||||||
|
are replaced with underscores, other characters are encoded as their unicode codepoint. Name is truncated
|
||||||
|
to 64 characters. Multiple different member names may map to a single slot name."""
|
||||||
|
|
||||||
|
def replace_char(match):
|
||||||
|
c = match.group(0)
|
||||||
|
return '_' if c in '-.' else "u{:04d}".format(ord(c))
|
||||||
|
|
||||||
|
slot_name = re.sub('[^a-z0-9_]', replace_char, member_name.lower())
|
||||||
|
return slot_name[0:63]
|
||||||
|
|
||||||
|
|
||||||
def parse_connection_string(value):
|
def parse_connection_string(value):
|
||||||
"""Original Governor stores connection strings for each cluster members if a following format:
|
"""Original Governor stores connection strings for each cluster members if a following format:
|
||||||
postgres://{username}:{password}@{connect_address}/postgres
|
postgres://{username}:{password}@{connect_address}/postgres
|
||||||
@@ -107,12 +127,29 @@ class Member(namedtuple('Member', 'index,name,session,data')):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def conn_url(self):
|
def conn_url(self):
|
||||||
return self.data.get('conn_url')
|
conn_url = self.data.get('conn_url')
|
||||||
|
conn_kwargs = self.data.get('conn_kwargs')
|
||||||
|
if conn_url:
|
||||||
|
return conn_url
|
||||||
|
|
||||||
|
if conn_kwargs:
|
||||||
|
conn_url = 'postgresql://{host}:{port}'.format(
|
||||||
|
host=conn_kwargs.get('host'),
|
||||||
|
port=conn_kwargs.get('port'),
|
||||||
|
)
|
||||||
|
self.data['conn_url'] = conn_url
|
||||||
|
return conn_url
|
||||||
|
|
||||||
def conn_kwargs(self, auth=None):
|
def conn_kwargs(self, auth=None):
|
||||||
|
defaults = {
|
||||||
|
"host": "",
|
||||||
|
"port": "",
|
||||||
|
"database": ""
|
||||||
|
}
|
||||||
ret = self.data.get('conn_kwargs')
|
ret = self.data.get('conn_kwargs')
|
||||||
if ret:
|
if ret:
|
||||||
ret = ret.copy()
|
defaults.update(ret)
|
||||||
|
ret = defaults
|
||||||
else:
|
else:
|
||||||
r = urlparse(self.conn_url)
|
r = urlparse(self.conn_url)
|
||||||
ret = {
|
ret = {
|
||||||
@@ -158,6 +195,26 @@ class Member(namedtuple('Member', 'index,name,session,data')):
|
|||||||
return self.state == 'running'
|
return self.state == 'running'
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteMember(Member):
|
||||||
|
""" Represents a remote master for a standby cluster
|
||||||
|
"""
|
||||||
|
def __new__(cls, name, data):
|
||||||
|
return super(RemoteMember, cls).__new__(cls, None, name, None, data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def allowed_keys():
|
||||||
|
return ('primary_slot_name',
|
||||||
|
'create_replica_methods',
|
||||||
|
'restore_command',
|
||||||
|
'archive_cleanup_command',
|
||||||
|
'recovery_min_apply_delay',
|
||||||
|
'no_replication_slot')
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
if name in RemoteMember.allowed_keys():
|
||||||
|
return self.data.get(name)
|
||||||
|
|
||||||
|
|
||||||
class Leader(namedtuple('Leader', 'index,session,member')):
|
class Leader(namedtuple('Leader', 'index,session,member')):
|
||||||
|
|
||||||
"""Immutable object (namedtuple) which represents leader key.
|
"""Immutable object (namedtuple) which represents leader key.
|
||||||
@@ -235,14 +292,24 @@ class ClusterConfig(namedtuple('ClusterConfig', 'index,data,modify_index')):
|
|||||||
def from_node(index, data, modify_index=None):
|
def from_node(index, data, modify_index=None):
|
||||||
"""
|
"""
|
||||||
>>> ClusterConfig.from_node(1, '{') is None
|
>>> ClusterConfig.from_node(1, '{') is None
|
||||||
True
|
False
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(data)
|
data = json.loads(data)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
data = None
|
||||||
return ClusterConfig(index, data, modify_index or index)
|
modify_index = 0
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
return ClusterConfig(index, data, index if modify_index is None else modify_index)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def permanent_slots(self):
|
||||||
|
return isinstance(self.data, dict) and (
|
||||||
|
self.data.get('permanent_replication_slots') or
|
||||||
|
self.data.get('permanent_slots') or self.data.get('slots')
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
|
||||||
class SyncState(namedtuple('SyncState', 'index,leader,sync_standby')):
|
class SyncState(namedtuple('SyncState', 'index,leader,sync_standby')):
|
||||||
@@ -349,14 +416,73 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
|
|||||||
candidates = [m for m in self.members if m.clonefrom and m.is_running and m.name not in exclude]
|
candidates = [m for m in self.members if m.clonefrom and m.is_running and m.name not in exclude]
|
||||||
return candidates[randint(0, len(candidates) - 1)] if candidates else self.leader
|
return candidates[randint(0, len(candidates) - 1)] if candidates else self.leader
|
||||||
|
|
||||||
|
def check_mode(self, mode):
|
||||||
|
return bool(self.config and parse_bool(self.config.data.get(mode)))
|
||||||
|
|
||||||
def is_paused(self):
|
def is_paused(self):
|
||||||
return self.config and self.config.data.get('pause', False) or False
|
return self.check_mode('pause')
|
||||||
|
|
||||||
def is_synchronous_mode(self):
|
def is_synchronous_mode(self):
|
||||||
return bool(self.config and self.config.data.get('synchronous_mode'))
|
return self.check_mode('synchronous_mode')
|
||||||
|
|
||||||
def is_synchronous_mode_strict(self):
|
def get_replication_slots(self, name, role):
|
||||||
return bool(self.config and self.config.data.get('synchronous_mode_strict'))
|
# if the replicatefrom tag is set on the member - we should not create the replication slot for it on
|
||||||
|
# the current master, because that member would replicate from elsewhere. We still create the slot if
|
||||||
|
# the replicatefrom destination member is currently not a member of the cluster (fallback to the
|
||||||
|
# master), or if replicatefrom destination member happens to be the current master
|
||||||
|
if role in ('master', 'standby_leader'):
|
||||||
|
slot_members = [m.name for m in self.members if m.name != name and
|
||||||
|
(m.replicatefrom is None or m.replicatefrom == name or
|
||||||
|
not self.has_member(m.replicatefrom))]
|
||||||
|
permanent_slots = (self.config and self.config.permanent_slots or {}).copy()
|
||||||
|
else:
|
||||||
|
# only manage slots for replicas that replicate from this one, except for the leader among them
|
||||||
|
slot_members = [m.name for m in self.members if m.replicatefrom == name and m.name != self.leader.name]
|
||||||
|
permanent_slots = {}
|
||||||
|
|
||||||
|
slots = {slot_name_from_member_name(name): {'type': 'physical'} for name in slot_members}
|
||||||
|
|
||||||
|
if len(slots) < len(slot_members):
|
||||||
|
# Find which names are conflicting for a nicer error message
|
||||||
|
slot_conflicts = defaultdict(list)
|
||||||
|
for name in slot_members:
|
||||||
|
slot_conflicts[slot_name_from_member_name(name)].append(name)
|
||||||
|
logger.error("Following cluster members share a replication slot name: %s",
|
||||||
|
"; ".join("{} map to {}".format(", ".join(v), k)
|
||||||
|
for k, v in slot_conflicts.items() if len(v) > 1))
|
||||||
|
|
||||||
|
# "merge" replication slots for members with permanent_replication_slots
|
||||||
|
for name, value in permanent_slots.items():
|
||||||
|
if not slot_name_re.match(name):
|
||||||
|
logger.error("Invalid permanent replication slot name '%s'", name)
|
||||||
|
logger.error("Slot name may only contain lower case letters, numbers, and the underscore chars")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if name in slots:
|
||||||
|
logger.error("Permanent replication slot {'%s': %s} is conflicting with" +
|
||||||
|
" physical replication slot for cluster member", name, value)
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = deepcopy(value)
|
||||||
|
if not value:
|
||||||
|
value = {'type': 'physical'}
|
||||||
|
|
||||||
|
if isinstance(value, dict):
|
||||||
|
if 'type' not in value:
|
||||||
|
value['type'] = 'logical' if value.get('database') and value.get('plugin') else 'physical'
|
||||||
|
|
||||||
|
if value['type'] == 'physical' or value['type'] == 'logical' \
|
||||||
|
and value.get('database') and value.get('plugin'):
|
||||||
|
slots[name] = value
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.error("Bad value for slot '%s' in permanent_slots: %s", name, permanent_slots[name])
|
||||||
|
|
||||||
|
return slots
|
||||||
|
|
||||||
|
def has_permanent_logical_slots(self, name):
|
||||||
|
slots = self.get_replication_slots(name, 'master').values()
|
||||||
|
return any(v for v in slots if v.get("type") == "logical")
|
||||||
|
|
||||||
|
|
||||||
@six.add_metaclass(abc.ABCMeta)
|
@six.add_metaclass(abc.ABCMeta)
|
||||||
@@ -378,7 +504,7 @@ class AbstractDCS(object):
|
|||||||
i.e.: `zookeeper` for zookeeper, `etcd` for etcd, etc...
|
i.e.: `zookeeper` for zookeeper, `etcd` for etcd, etc...
|
||||||
"""
|
"""
|
||||||
self._name = config['name']
|
self._name = config['name']
|
||||||
self._base_path = os.path.join('/', config.get('namespace', '/service/').strip('/'), config['scope'])
|
self._base_path = re.sub('/+', '/', '/'.join(['', config.get('namespace', 'service'), config['scope']]))
|
||||||
self._set_loop_wait(config.get('loop_wait', 10))
|
self._set_loop_wait(config.get('loop_wait', 10))
|
||||||
|
|
||||||
self._ctl = bool(config.get('patronictl', False))
|
self._ctl = bool(config.get('patronictl', False))
|
||||||
@@ -494,7 +620,7 @@ class AbstractDCS(object):
|
|||||||
You have to use CAS (Compare And Swap) operation in order to update leader key,
|
You have to use CAS (Compare And Swap) operation in order to update leader key,
|
||||||
for example for etcd `prevValue` parameter must be used."""
|
for example for etcd `prevValue` parameter must be used."""
|
||||||
|
|
||||||
def update_leader(self, last_operation):
|
def update_leader(self, last_operation, access_is_restricted=False):
|
||||||
"""Update leader key (or session) ttl and optime/leader
|
"""Update leader key (or session) ttl and optime/leader
|
||||||
|
|
||||||
:param last_operation: absolute xlog location in bytes
|
:param last_operation: absolute xlog location in bytes
|
||||||
|
|||||||
+97
-11
@@ -2,6 +2,7 @@ from __future__ import absolute_import
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import time
|
import time
|
||||||
@@ -12,7 +13,7 @@ from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, M
|
|||||||
from patroni.exceptions import DCSError
|
from patroni.exceptions import DCSError
|
||||||
from patroni.utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port
|
from patroni.utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port
|
||||||
from urllib3.exceptions import HTTPError
|
from urllib3.exceptions import HTTPError
|
||||||
from six.moves.urllib.parse import urlencode, urlparse
|
from six.moves.urllib.parse import urlencode, urlparse, quote
|
||||||
from six.moves.http_client import HTTPException
|
from six.moves.http_client import HTTPException
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -91,6 +92,8 @@ class HTTPClient(object):
|
|||||||
data = '{' + ttl + '}'
|
data = '{' + ttl + '}'
|
||||||
else:
|
else:
|
||||||
data = data[:-1] + ', ' + ttl + '}'
|
data = data[:-1] + ', ' + ttl + '}'
|
||||||
|
if isinstance(params, list): # starting from v1.1.0 python-consul switched from `dict` to `list` for params
|
||||||
|
params = {k: v for k, v in params}
|
||||||
kwargs = {'retries': 0, 'preload_content': False, 'body': data}
|
kwargs = {'retries': 0, 'preload_content': False, 'body': data}
|
||||||
if method == 'get' and isinstance(params, dict) and 'index' in params:
|
if method == 'get' and isinstance(params, dict) and 'index' in params:
|
||||||
kwargs['timeout'] = (float(params['wait'][:-1]) if 'wait' in params else 300) + 1
|
kwargs['timeout'] = (float(params['wait'][:-1]) if 'wait' in params else 300) + 1
|
||||||
@@ -131,6 +134,31 @@ def catch_consul_errors(func):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def force_if_last_failed(func):
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if wrapper.last_result is False:
|
||||||
|
kwargs['force'] = True
|
||||||
|
wrapper.last_result = func(*args, **kwargs)
|
||||||
|
return wrapper.last_result
|
||||||
|
|
||||||
|
wrapper.last_result = None
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def service_name_from_scope_name(scope_name):
|
||||||
|
"""Translate scope name to service name which can be used in dns.
|
||||||
|
|
||||||
|
230 = 253 - len('replica.') - len('.service.consul')
|
||||||
|
"""
|
||||||
|
|
||||||
|
def replace_char(match):
|
||||||
|
c = match.group(0)
|
||||||
|
return '-' if c in '. _' else "u{:04d}".format(ord(c))
|
||||||
|
|
||||||
|
service_name = re.sub(r'[^a-z0-9\-]', replace_char, scope_name.lower())
|
||||||
|
return service_name[0:230]
|
||||||
|
|
||||||
|
|
||||||
class Consul(AbstractDCS):
|
class Consul(AbstractDCS):
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
@@ -142,7 +170,6 @@ class Consul(AbstractDCS):
|
|||||||
retry_exceptions=(ConsulInternalError, HTTPException,
|
retry_exceptions=(ConsulInternalError, HTTPException,
|
||||||
HTTPError, socket.error, socket.timeout))
|
HTTPError, socket.error, socket.timeout))
|
||||||
|
|
||||||
self._my_member_data = {}
|
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if 'url' in config:
|
if 'url' in config:
|
||||||
r = urlparse(config['url'])
|
r = urlparse(config['url'])
|
||||||
@@ -173,6 +200,13 @@ class Consul(AbstractDCS):
|
|||||||
self.set_ttl(config.get('ttl') or 30)
|
self.set_ttl(config.get('ttl') or 30)
|
||||||
self._last_session_refresh = 0
|
self._last_session_refresh = 0
|
||||||
self.__session_checks = config.get('checks')
|
self.__session_checks = config.get('checks')
|
||||||
|
self._register_service = config.get('register_service', False)
|
||||||
|
if self._register_service:
|
||||||
|
self._service_name = service_name_from_scope_name(self._scope)
|
||||||
|
if self._scope != self._service_name:
|
||||||
|
logger.warning('Using %s as consul service name instead of scope name %s', self._service_name,
|
||||||
|
self._scope)
|
||||||
|
self._service_check_interval = config.get('service_check_interval', '5s')
|
||||||
if not self._ctl:
|
if not self._ctl:
|
||||||
self.create_session()
|
self.create_session()
|
||||||
|
|
||||||
@@ -254,7 +288,7 @@ class Consul(AbstractDCS):
|
|||||||
nodes = {}
|
nodes = {}
|
||||||
for node in results:
|
for node in results:
|
||||||
node['Value'] = (node['Value'] or b'').decode('utf-8')
|
node['Value'] = (node['Value'] or b'').decode('utf-8')
|
||||||
nodes[os.path.relpath(node['Key'], path)] = node
|
nodes[os.path.relpath(node['Key'], path).replace('\\', '/')] = node
|
||||||
|
|
||||||
# get initialize flag
|
# get initialize flag
|
||||||
initialize = nodes.get(self._INITIALIZE)
|
initialize = nodes.get(self._INITIALIZE)
|
||||||
@@ -304,30 +338,81 @@ class Consul(AbstractDCS):
|
|||||||
logger.exception('get_cluster')
|
logger.exception('get_cluster')
|
||||||
raise ConsulError('Consul is not responding properly')
|
raise ConsulError('Consul is not responding properly')
|
||||||
|
|
||||||
|
@catch_consul_errors
|
||||||
def touch_member(self, data, ttl=None, permanent=False):
|
def touch_member(self, data, ttl=None, permanent=False):
|
||||||
cluster = self.cluster
|
cluster = self.cluster
|
||||||
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
|
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
|
||||||
create_member = not permanent and self.refresh_session()
|
create_member = not permanent and self.refresh_session()
|
||||||
|
|
||||||
if member and (create_member or member.session != self._session):
|
if member and (create_member or member.session != self._session):
|
||||||
try:
|
self._client.kv.delete(self.member_path)
|
||||||
self._client.kv.delete(self.member_path)
|
create_member = True
|
||||||
create_member = True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not create_member and member and deep_compare(data, self._my_member_data):
|
if not create_member and member and deep_compare(data, member.data):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
args = {} if permanent else {'acquire': self._session}
|
args = {} if permanent else {'acquire': self._session}
|
||||||
self._client.kv.put(self.member_path, json.dumps(data, separators=(',', ':')), **args)
|
self._client.kv.put(self.member_path, json.dumps(data, separators=(',', ':')), **args)
|
||||||
self._my_member_data = data
|
if self._register_service:
|
||||||
|
self.update_service(not create_member and member and member.data or {}, data)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('touch_member')
|
logger.exception('touch_member')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@catch_consul_errors
|
||||||
|
def register_service(self, service_name, **kwargs):
|
||||||
|
logger.info('Register service %s, params %s', service_name, kwargs)
|
||||||
|
return self._client.agent.service.register(service_name, **kwargs)
|
||||||
|
|
||||||
|
@catch_consul_errors
|
||||||
|
def deregister_service(self, service_id):
|
||||||
|
logger.info('Deregister service %s', service_id)
|
||||||
|
# service_id can contain special characters, but is used as part of uri in deregister request
|
||||||
|
service_id = quote(service_id)
|
||||||
|
return self._client.agent.service.deregister(service_id)
|
||||||
|
|
||||||
|
def _update_service(self, data):
|
||||||
|
service_name = self._service_name
|
||||||
|
role = data['role'].replace('_', '-')
|
||||||
|
state = data['state']
|
||||||
|
api_parts = urlparse(data['api_url'])
|
||||||
|
api_parts = api_parts._replace(path='/{0}'.format(role))
|
||||||
|
conn_parts = urlparse(data['conn_url'])
|
||||||
|
check = base.Check.http(api_parts.geturl(), self._service_check_interval, deregister=self._client.http.ttl * 10)
|
||||||
|
params = {
|
||||||
|
'service_id': '{0}/{1}'.format(self._scope, self._name),
|
||||||
|
'address': conn_parts.hostname,
|
||||||
|
'port': conn_parts.port,
|
||||||
|
'check': check,
|
||||||
|
'tags': [role]
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == 'stopped':
|
||||||
|
return self.deregister_service(params['service_id'])
|
||||||
|
|
||||||
|
if role in ['master', 'replica', 'standby-leader']:
|
||||||
|
if state != 'running':
|
||||||
|
return
|
||||||
|
return self.register_service(service_name, **params)
|
||||||
|
|
||||||
|
logger.warning('Could not register service: unknown role type %s', role)
|
||||||
|
|
||||||
|
@force_if_last_failed
|
||||||
|
def update_service(self, old_data, new_data, force=False):
|
||||||
|
update = False
|
||||||
|
|
||||||
|
for key in ['role', 'api_url', 'conn_url', 'state']:
|
||||||
|
if key not in new_data:
|
||||||
|
logger.warning('Could not register service: not enough params in member data')
|
||||||
|
return
|
||||||
|
if old_data.get(key) != new_data[key]:
|
||||||
|
update = True
|
||||||
|
|
||||||
|
if force or update:
|
||||||
|
return self._update_service(new_data)
|
||||||
|
|
||||||
@catch_consul_errors
|
@catch_consul_errors
|
||||||
def _do_attempt_to_acquire_leader(self, kwargs):
|
def _do_attempt_to_acquire_leader(self, kwargs):
|
||||||
return self.retry(self._client.kv.put, self.leader_path, self._name, **kwargs)
|
return self.retry(self._client.kv.put, self.leader_path, self._name, **kwargs)
|
||||||
@@ -339,6 +424,7 @@ class Consul(AbstractDCS):
|
|||||||
ret = self._do_attempt_to_acquire_leader({} if permanent else {'acquire': self._session})
|
ret = self._do_attempt_to_acquire_leader({} if permanent else {'acquire': self._session})
|
||||||
if not ret:
|
if not ret:
|
||||||
logger.info('Could not take out TTL lock')
|
logger.info('Could not take out TTL lock')
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def take_leader(self):
|
def take_leader(self):
|
||||||
@@ -406,7 +492,7 @@ class Consul(AbstractDCS):
|
|||||||
idx, _ = self._client.kv.get(self.leader_path, index=leader_index, wait=str(timeout) + 's')
|
idx, _ = self._client.kv.get(self.leader_path, index=leader_index, wait=str(timeout) + 's')
|
||||||
return str(idx) != str(leader_index)
|
return str(idx) != str(leader_index)
|
||||||
except (ConsulException, HTTPException, HTTPError, socket.error, socket.timeout):
|
except (ConsulException, HTTPException, HTTPError, socket.error, socket.timeout):
|
||||||
logging.exception('watch')
|
logger.exception('watch')
|
||||||
|
|
||||||
timeout = end_time - time.time()
|
timeout = end_time - time.time()
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -210,8 +210,11 @@ class Client(etcd.Client):
|
|||||||
self._machines_cache = self.machines
|
self._machines_cache = self.machines
|
||||||
if self._base_uri in self._machines_cache:
|
if self._base_uri in self._machines_cache:
|
||||||
self._machines_cache.remove(self._base_uri)
|
self._machines_cache.remove(self._base_uri)
|
||||||
except etcd.EtcdConnectionFailed:
|
except etcd.EtcdConnectionFailed as e:
|
||||||
self._update_machines_cache = True
|
if isinstance(e, etcd.EtcdWatchTimedOut) and self._machines_cache:
|
||||||
|
self._base_uri = self._next_server()
|
||||||
|
else:
|
||||||
|
self._update_machines_cache = True
|
||||||
if not response:
|
if not response:
|
||||||
raise
|
raise
|
||||||
return self._handle_server_response(response)
|
return self._handle_server_response(response)
|
||||||
@@ -445,7 +448,7 @@ class Etcd(AbstractDCS):
|
|||||||
def _load_cluster(self):
|
def _load_cluster(self):
|
||||||
try:
|
try:
|
||||||
result = self.retry(self._client.read, self.client_path(''), recursive=True)
|
result = self.retry(self._client.read, self.client_path(''), recursive=True)
|
||||||
nodes = {os.path.relpath(node.key, result.key): node for node in result.leaves}
|
nodes = {os.path.relpath(node.key, result.key).replace('\\', '/'): node for node in result.leaves}
|
||||||
|
|
||||||
# get initialize flag
|
# get initialize flag
|
||||||
initialize = nodes.get(self._INITIALIZE)
|
initialize = nodes.get(self._INITIALIZE)
|
||||||
@@ -493,7 +496,7 @@ class Etcd(AbstractDCS):
|
|||||||
@catch_etcd_errors
|
@catch_etcd_errors
|
||||||
def touch_member(self, data, ttl=None, permanent=False):
|
def touch_member(self, data, ttl=None, permanent=False):
|
||||||
data = json.dumps(data, separators=(',', ':'))
|
data = json.dumps(data, separators=(',', ':'))
|
||||||
return self.retry(self._client.set, self.member_path, data, None if permanent else ttl or self._ttl)
|
return self._client.set(self.member_path, data, None if permanent else ttl or self._ttl)
|
||||||
|
|
||||||
@catch_etcd_errors
|
@catch_etcd_errors
|
||||||
def take_leader(self):
|
def take_leader(self):
|
||||||
|
|||||||
+29
-19
@@ -29,7 +29,7 @@ class KubernetesRetriableException(k8s_client.rest.ApiException):
|
|||||||
self.headers = orig.headers
|
self.headers = orig.headers
|
||||||
|
|
||||||
|
|
||||||
class CoreV1Api(object):
|
class CoreV1ApiProxy(object):
|
||||||
|
|
||||||
def __init__(self, use_endpoints=False):
|
def __init__(self, use_endpoints=False):
|
||||||
self._api = k8s_client.CoreV1Api()
|
self._api = k8s_client.CoreV1Api()
|
||||||
@@ -55,6 +55,21 @@ class CoreV1Api(object):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def catch_kubernetes_errors(func):
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except k8s_client.rest.ApiException as e:
|
||||||
|
if e.status == 403:
|
||||||
|
logger.exception('Permission denied')
|
||||||
|
elif e.status != 409: # Object exists or conflict in resource_version
|
||||||
|
logger.exception('Unexpected error from Kubernetes API')
|
||||||
|
return False
|
||||||
|
except (RetryFailedError, HTTPException, HTTPError, socket.error, socket.timeout):
|
||||||
|
return False
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
class Kubernetes(AbstractDCS):
|
class Kubernetes(AbstractDCS):
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
@@ -84,7 +99,7 @@ class Kubernetes(AbstractDCS):
|
|||||||
port.update({n: p[n] for n in ('name', 'protocol') if p.get(n)})
|
port.update({n: p[n] for n in ('name', 'protocol') if p.get(n)})
|
||||||
ports.append(k8s_client.V1EndpointPort(**port))
|
ports.append(k8s_client.V1EndpointPort(**port))
|
||||||
self.__subsets = [k8s_client.V1EndpointSubset(addresses=addresses, ports=ports)]
|
self.__subsets = [k8s_client.V1EndpointSubset(addresses=addresses, ports=ports)]
|
||||||
self._api = CoreV1Api(use_endpoints)
|
self._api = CoreV1ApiProxy(use_endpoints)
|
||||||
self.set_retry_timeout(config['retry_timeout'])
|
self.set_retry_timeout(config['retry_timeout'])
|
||||||
self.set_ttl(config.get('ttl') or 30)
|
self.set_ttl(config.get('ttl') or 30)
|
||||||
self._leader_observed_record = {}
|
self._leader_observed_record = {}
|
||||||
@@ -96,16 +111,6 @@ class Kubernetes(AbstractDCS):
|
|||||||
def retry(self, *args, **kwargs):
|
def retry(self, *args, **kwargs):
|
||||||
return self._retry.copy()(*args, **kwargs)
|
return self._retry.copy()(*args, **kwargs)
|
||||||
|
|
||||||
def catch_kubernetes_errors(func):
|
|
||||||
@functools.wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
try:
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
except (RetryFailedError, k8s_client.rest.ApiException,
|
|
||||||
HTTPException, HTTPError, socket.error, socket.timeout):
|
|
||||||
return False
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
def client_path(self, path):
|
def client_path(self, path):
|
||||||
return super(Kubernetes, self).client_path(path)[1:].replace('/', '-')
|
return super(Kubernetes, self).client_path(path)[1:].replace('/', '-')
|
||||||
|
|
||||||
@@ -147,7 +152,8 @@ class Kubernetes(AbstractDCS):
|
|||||||
|
|
||||||
# get global dynamic configuration
|
# get global dynamic configuration
|
||||||
config = ClusterConfig.from_node(metadata and metadata.resource_version,
|
config = ClusterConfig.from_node(metadata and metadata.resource_version,
|
||||||
annotations.get(self._CONFIG) or '{}')
|
annotations.get(self._CONFIG) or '{}',
|
||||||
|
metadata.resource_version if self._CONFIG in annotations else 0)
|
||||||
|
|
||||||
# get timeline history
|
# get timeline history
|
||||||
history = TimelineHistory.from_node(metadata and metadata.resource_version,
|
history = TimelineHistory.from_node(metadata and metadata.resource_version,
|
||||||
@@ -156,7 +162,7 @@ class Kubernetes(AbstractDCS):
|
|||||||
leader = nodes.get(self.leader_path)
|
leader = nodes.get(self.leader_path)
|
||||||
metadata = leader and leader.metadata
|
metadata = leader and leader.metadata
|
||||||
self._leader_resource_version = metadata.resource_version if metadata else None
|
self._leader_resource_version = metadata.resource_version if metadata else None
|
||||||
self._leader_observed_subsets = leader.subsets if self.__subsets and leader else []
|
self._leader_observed_subsets = leader.subsets if self.__subsets and leader and leader.subsets else []
|
||||||
annotations = metadata and metadata.annotations or {}
|
annotations = metadata and metadata.annotations or {}
|
||||||
|
|
||||||
# get last leader operation
|
# get last leader operation
|
||||||
@@ -274,7 +280,7 @@ class Kubernetes(AbstractDCS):
|
|||||||
def _update_leader(self):
|
def _update_leader(self):
|
||||||
"""Unused"""
|
"""Unused"""
|
||||||
|
|
||||||
def update_leader(self, last_operation):
|
def update_leader(self, last_operation, access_is_restricted=False):
|
||||||
now = datetime.datetime.now(tzutc).isoformat()
|
now = datetime.datetime.now(tzutc).isoformat()
|
||||||
annotations = {self._LEADER: self._name, 'ttl': str(self._ttl), 'renewTime': now,
|
annotations = {self._LEADER: self._name, 'ttl': str(self._ttl), 'renewTime': now,
|
||||||
'acquireTime': self._leader_observed_record.get('acquireTime') or now,
|
'acquireTime': self._leader_observed_record.get('acquireTime') or now,
|
||||||
@@ -282,7 +288,11 @@ class Kubernetes(AbstractDCS):
|
|||||||
if last_operation:
|
if last_operation:
|
||||||
annotations[self._OPTIME] = last_operation
|
annotations[self._OPTIME] = last_operation
|
||||||
|
|
||||||
ret = self.patch_or_create(self.leader_path, annotations, self._leader_resource_version, subsets=self.__subsets)
|
subsets = self.__subsets
|
||||||
|
if subsets is not None and access_is_restricted:
|
||||||
|
subsets = []
|
||||||
|
|
||||||
|
ret = self.patch_or_create(self.leader_path, annotations, self._leader_resource_version, subsets=subsets)
|
||||||
if ret:
|
if ret:
|
||||||
self._leader_resource_version = ret.metadata.resource_version
|
self._leader_resource_version = ret.metadata.resource_version
|
||||||
return ret
|
return ret
|
||||||
@@ -302,7 +312,8 @@ class Kubernetes(AbstractDCS):
|
|||||||
else:
|
else:
|
||||||
annotations['acquireTime'] = self._leader_observed_record.get('acquireTime') or now
|
annotations['acquireTime'] = self._leader_observed_record.get('acquireTime') or now
|
||||||
annotations['transitions'] = str(transitions)
|
annotations['transitions'] = str(transitions)
|
||||||
ret = self.patch_or_create(self.leader_path, annotations, self._leader_resource_version, subsets=self.__subsets)
|
subsets = [] if self.__subsets else None
|
||||||
|
ret = self.patch_or_create(self.leader_path, annotations, self._leader_resource_version, subsets=subsets)
|
||||||
if ret:
|
if ret:
|
||||||
self._leader_resource_version = ret.metadata.resource_version
|
self._leader_resource_version = ret.metadata.resource_version
|
||||||
else:
|
else:
|
||||||
@@ -362,7 +373,6 @@ class Kubernetes(AbstractDCS):
|
|||||||
def delete_cluster(self):
|
def delete_cluster(self):
|
||||||
self.retry(self._api.delete_collection_namespaced_kind, self._namespace, label_selector=self._label_selector)
|
self.retry(self._api.delete_collection_namespaced_kind, self._namespace, label_selector=self._label_selector)
|
||||||
|
|
||||||
@catch_kubernetes_errors
|
|
||||||
def set_history_value(self, value):
|
def set_history_value(self, value):
|
||||||
patch = bool(self.cluster and self.cluster.config and self.cluster.config.index)
|
patch = bool(self.cluster and self.cluster.config and self.cluster.config.index)
|
||||||
return self.patch_or_create(self.config_path, {self._HISTORY: value}, None, patch, False)
|
return self.patch_or_create(self.config_path, {self._HISTORY: value}, None, patch, False)
|
||||||
@@ -395,7 +405,7 @@ class Kubernetes(AbstractDCS):
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception('watch')
|
logger.exception('watch')
|
||||||
|
|
||||||
timeout = end_time - time.time()
|
timeout = end_time - time.time()
|
||||||
|
|
||||||
|
|||||||
+34
-44
@@ -37,7 +37,9 @@ class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
|
|||||||
`connect_timeout` (negotiated session timeout) as the second element."""
|
`connect_timeout` (negotiated session timeout) as the second element."""
|
||||||
|
|
||||||
args = list(args)
|
args = list(args)
|
||||||
if len(args) == 1:
|
if len(args) == 0: # kazoo 2.6.0 slightly changed the way how it calls create_connection method
|
||||||
|
kwargs['timeout'] = max(self._connect_timeout, kwargs.get('timeout', self._connect_timeout*10)/10.0)
|
||||||
|
elif len(args) == 1:
|
||||||
args.append(self._connect_timeout)
|
args.append(self._connect_timeout)
|
||||||
else:
|
else:
|
||||||
args[1] = max(self._connect_timeout, args[1]/10.0)
|
args[1] = max(self._connect_timeout, args[1]/10.0)
|
||||||
@@ -59,7 +61,6 @@ class ZooKeeper(AbstractDCS):
|
|||||||
max_delay=1, max_tries=-1, sleep_func=time.sleep))
|
max_delay=1, max_tries=-1, sleep_func=time.sleep))
|
||||||
self._client.add_listener(self.session_listener)
|
self._client.add_listener(self.session_listener)
|
||||||
|
|
||||||
self._my_member_data = {}
|
|
||||||
self._fetch_cluster = True
|
self._fetch_cluster = True
|
||||||
|
|
||||||
self._orig_kazoo_connect = self._client._connection._connect
|
self._orig_kazoo_connect = self._client._connection._connect
|
||||||
@@ -208,45 +209,52 @@ class ZooKeeper(AbstractDCS):
|
|||||||
self.cluster_watcher(None)
|
self.cluster_watcher(None)
|
||||||
raise ZooKeeperError('ZooKeeper in not responding properly')
|
raise ZooKeeperError('ZooKeeper in not responding properly')
|
||||||
|
|
||||||
def _create(self, path, value, **kwargs):
|
def _create(self, path, value, retry=False, ephemeral=False):
|
||||||
try:
|
try:
|
||||||
self._client.retry(self._client.create, path, value.encode('utf-8'), **kwargs)
|
if retry:
|
||||||
|
self._client.retry(self._client.create, path, value, makepath=True, ephemeral=ephemeral)
|
||||||
|
else:
|
||||||
|
self._client.create_async(path, value, makepath=True, ephemeral=ephemeral).get(timeout=1)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
logger.exception('Failed to create %s', path)
|
||||||
|
return False
|
||||||
|
|
||||||
def attempt_to_acquire_leader(self, permanent=False):
|
def attempt_to_acquire_leader(self, permanent=False):
|
||||||
ret = self._create(self.leader_path, self._name, makepath=True, ephemeral=not permanent)
|
ret = self._create(self.leader_path, self._name.encode('utf-8'), retry=True, ephemeral=not permanent)
|
||||||
if not ret:
|
if not ret:
|
||||||
logger.info('Could not take out TTL lock')
|
logger.info('Could not take out TTL lock')
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def __set_failover_or_sync_state_value(self, key, value, index=None):
|
def _set_or_create(self, key, value, index=None, retry=False, do_not_create_empty=False):
|
||||||
|
value = value.encode('utf-8')
|
||||||
try:
|
try:
|
||||||
self._client.retry(self._client.set, key, value.encode('utf-8'), version=index or -1)
|
if retry:
|
||||||
|
self._client.retry(self._client.set, key, value, version=index or -1)
|
||||||
|
else:
|
||||||
|
self._client.set_async(key, value, version=index or -1).get(timeout=1)
|
||||||
return True
|
return True
|
||||||
except NoNodeError:
|
except NoNodeError:
|
||||||
return value == '' or (index is None and self._create(key, value))
|
if do_not_create_empty and not value:
|
||||||
|
return True
|
||||||
|
elif index is None:
|
||||||
|
return self._create(key, value, retry)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception('set_failover_value')
|
logger.exception('Failed to update %s', key)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def set_failover_value(self, value, index=None):
|
def set_failover_value(self, value, index=None):
|
||||||
return self.__set_failover_or_sync_state_value(self.failover_path, value, index)
|
return self._set_or_create(self.failover_path, value, index)
|
||||||
|
|
||||||
def set_config_value(self, value, index=None):
|
def set_config_value(self, value, index=None):
|
||||||
try:
|
return self._set_or_create(self.config_path, value, index, retry=True)
|
||||||
self._client.retry(self._client.set, self.config_path, value.encode('utf-8'), version=index or -1)
|
|
||||||
return True
|
|
||||||
except NoNodeError:
|
|
||||||
return index is None and self._create(self.config_path, value)
|
|
||||||
except Exception:
|
|
||||||
logging.exception('set_config_value')
|
|
||||||
return False
|
|
||||||
|
|
||||||
def initialize(self, create_new=True, sysid=""):
|
def initialize(self, create_new=True, sysid=""):
|
||||||
return self._create(self.initialize_path, sysid, makepath=True) if create_new \
|
sysid = sysid.encode('utf-8')
|
||||||
else self._client.retry(self._client.set, self.initialize_path, sysid.encode("utf-8"))
|
return self._create(self.initialize_path, sysid, retry=True) if create_new \
|
||||||
|
else self._client.retry(self._client.set, self.initialize_path, sysid)
|
||||||
|
|
||||||
def touch_member(self, data, ttl=None, permanent=False):
|
def touch_member(self, data, ttl=None, permanent=False):
|
||||||
cluster = self.cluster
|
cluster = self.cluster
|
||||||
@@ -262,13 +270,12 @@ class ZooKeeper(AbstractDCS):
|
|||||||
member = None
|
member = None
|
||||||
|
|
||||||
if member:
|
if member:
|
||||||
if deep_compare(data, self._my_member_data):
|
if deep_compare(data, member.data):
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
self._client.create_async(self.member_path, encoded_data, makepath=True,
|
self._client.create_async(self.member_path, encoded_data, makepath=True,
|
||||||
ephemeral=not permanent).get(timeout=1)
|
ephemeral=not permanent).get(timeout=1)
|
||||||
self._my_member_data = data
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not isinstance(e, NodeExistsError):
|
if not isinstance(e, NodeExistsError):
|
||||||
@@ -276,7 +283,6 @@ class ZooKeeper(AbstractDCS):
|
|||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
self._client.set_async(self.member_path, encoded_data).get(timeout=1)
|
self._client.set_async(self.member_path, encoded_data).get(timeout=1)
|
||||||
self._my_member_data = data
|
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('touch_member')
|
logger.exception('touch_member')
|
||||||
@@ -286,30 +292,14 @@ class ZooKeeper(AbstractDCS):
|
|||||||
def take_leader(self):
|
def take_leader(self):
|
||||||
return self.attempt_to_acquire_leader()
|
return self.attempt_to_acquire_leader()
|
||||||
|
|
||||||
def __write_leader_optime_or_history_value(self, key, value):
|
|
||||||
value = value.encode('utf-8')
|
|
||||||
try:
|
|
||||||
self._client.set_async(key, value).get(timeout=1)
|
|
||||||
return True
|
|
||||||
except NoNodeError:
|
|
||||||
try:
|
|
||||||
self._client.create_async(key, value, makepath=True).get(timeout=1)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
logger.exception('Failed to create %s', key)
|
|
||||||
except Exception:
|
|
||||||
logger.exception('Failed to update %s', key)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _write_leader_optime(self, last_operation):
|
def _write_leader_optime(self, last_operation):
|
||||||
return self.__write_leader_optime_or_history_value(self.leader_optime_path, last_operation)
|
return self._set_or_create(self.leader_optime_path, last_operation)
|
||||||
|
|
||||||
def _update_leader(self):
|
def _update_leader(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def delete_leader(self):
|
def delete_leader(self):
|
||||||
self._client.restart()
|
self._client.restart()
|
||||||
self._my_member_data = None
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cancel_initialization(self):
|
def _cancel_initialization(self):
|
||||||
@@ -330,10 +320,10 @@ class ZooKeeper(AbstractDCS):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def set_history_value(self, value):
|
def set_history_value(self, value):
|
||||||
return self.__write_leader_optime_or_history_value(self.history_path, value)
|
return self._set_or_create(self.history_path, value)
|
||||||
|
|
||||||
def set_sync_state_value(self, value, index=None):
|
def set_sync_state_value(self, value, index=None):
|
||||||
return self.__set_failover_or_sync_state_value(self.sync_path, value, index)
|
return self._set_or_create(self.sync_path, value, index, retry=True, do_not_create_empty=True)
|
||||||
|
|
||||||
def delete_sync_state(self, index=None):
|
def delete_sync_state(self, index=None):
|
||||||
return self.set_sync_state_value("{}", index)
|
return self.set_sync_state_value("{}", index)
|
||||||
|
|||||||
+219
-54
@@ -6,13 +6,15 @@ import psycopg2
|
|||||||
import requests
|
import requests
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from multiprocessing.pool import ThreadPool
|
from multiprocessing.pool import ThreadPool
|
||||||
from patroni.async_executor import AsyncExecutor, CriticalTask
|
from patroni.async_executor import AsyncExecutor, CriticalTask
|
||||||
from patroni.exceptions import DCSError, PostgresConnectionException, PatroniException
|
from patroni.exceptions import DCSError, PostgresConnectionException, PatroniException
|
||||||
from patroni.postgresql import ACTION_ON_START
|
from patroni.postgresql import ACTION_ON_START, ACTION_ON_ROLE_CHANGE
|
||||||
from patroni.utils import polling_loop, tzutc
|
from patroni.utils import polling_loop, tzutc
|
||||||
|
from patroni.dcs import RemoteMember
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -57,6 +59,10 @@ class Ha(object):
|
|||||||
self.dcs = patroni.dcs
|
self.dcs = patroni.dcs
|
||||||
self.cluster = None
|
self.cluster = None
|
||||||
self.old_cluster = None
|
self.old_cluster = None
|
||||||
|
self._is_leader = False
|
||||||
|
self._is_leader_lock = RLock()
|
||||||
|
self._leader_access_is_restricted = False
|
||||||
|
self._was_paused = False
|
||||||
self._leader_timeline = None
|
self._leader_timeline = None
|
||||||
self.recovering = False
|
self.recovering = False
|
||||||
self._post_bootstrap_task = None
|
self._post_bootstrap_task = None
|
||||||
@@ -72,8 +78,43 @@ class Ha(object):
|
|||||||
# standby. Changes protected by _member_state_lock.
|
# standby. Changes protected by _member_state_lock.
|
||||||
self._disable_sync = 0
|
self._disable_sync = 0
|
||||||
|
|
||||||
|
# We need following property to avoid shutdown of postgres when join of Patroni to the postgres
|
||||||
|
# already running as replica was aborted due to cluster not beeing initialized in DCS.
|
||||||
|
self._join_aborted = False
|
||||||
|
|
||||||
|
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:
|
||||||
|
return self.cluster.check_mode(mode)
|
||||||
|
else:
|
||||||
|
return self.patroni.config.check_mode(mode)
|
||||||
|
|
||||||
def is_paused(self):
|
def is_paused(self):
|
||||||
return self.cluster and self.cluster.is_paused()
|
return self.check_mode('pause')
|
||||||
|
|
||||||
|
def get_standby_cluster_config(self):
|
||||||
|
if self.cluster and self.cluster.config and self.cluster.config.modify_index:
|
||||||
|
config = self.cluster.config.data
|
||||||
|
else:
|
||||||
|
config = self.patroni.config.dynamic_configuration
|
||||||
|
return config.get('standby_cluster')
|
||||||
|
|
||||||
|
def is_standby_cluster(self):
|
||||||
|
config = self.get_standby_cluster_config()
|
||||||
|
# Check whether or not provided configuration describes a standby cluster
|
||||||
|
return isinstance(config, dict) and (config.get('host') or config.get('port') or config.get('restore_command'))
|
||||||
|
|
||||||
|
def is_leader(self):
|
||||||
|
with self._is_leader_lock:
|
||||||
|
return self._is_leader and not self._leader_access_is_restricted
|
||||||
|
|
||||||
|
def set_is_leader(self, value):
|
||||||
|
with self._is_leader_lock:
|
||||||
|
self._is_leader = value
|
||||||
|
|
||||||
|
def set_leader_access_is_restricted(self, value):
|
||||||
|
with self._is_leader_lock:
|
||||||
|
self._leader_access_is_restricted = value
|
||||||
|
|
||||||
def load_cluster_from_dcs(self):
|
def load_cluster_from_dcs(self):
|
||||||
cluster = self.dcs.get_cluster()
|
cluster = self.dcs.get_cluster()
|
||||||
@@ -83,10 +124,16 @@ class Ha(object):
|
|||||||
self.old_cluster = cluster
|
self.old_cluster = cluster
|
||||||
self.cluster = cluster
|
self.cluster = cluster
|
||||||
|
|
||||||
|
if self.cluster.is_unlocked() or self.cluster.leader.name != self.state_handler.name:
|
||||||
|
self.set_is_leader(False)
|
||||||
|
|
||||||
self._leader_timeline = None if cluster.is_unlocked() else cluster.leader.timeline
|
self._leader_timeline = None if cluster.is_unlocked() else cluster.leader.timeline
|
||||||
|
|
||||||
def acquire_lock(self):
|
def acquire_lock(self):
|
||||||
return self.dcs.attempt_to_acquire_leader()
|
self.set_leader_access_is_restricted(self.cluster.has_permanent_logical_slots(self.state_handler.name))
|
||||||
|
ret = self.dcs.attempt_to_acquire_leader()
|
||||||
|
self.set_is_leader(ret)
|
||||||
|
return ret
|
||||||
|
|
||||||
def update_lock(self, write_leader_optime=False):
|
def update_lock(self, write_leader_optime=False):
|
||||||
last_operation = None
|
last_operation = None
|
||||||
@@ -95,7 +142,8 @@ class Ha(object):
|
|||||||
last_operation = self.state_handler.last_operation()
|
last_operation = self.state_handler.last_operation()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('Exception when called state_handler.last_operation()')
|
logger.exception('Exception when called state_handler.last_operation()')
|
||||||
ret = self.dcs.update_leader(last_operation)
|
ret = self.dcs.update_leader(last_operation, self._leader_access_is_restricted)
|
||||||
|
self.set_is_leader(ret)
|
||||||
if ret:
|
if ret:
|
||||||
self.watchdog.keepalive()
|
self.watchdog.keepalive()
|
||||||
return ret
|
return ret
|
||||||
@@ -121,6 +169,10 @@ class Ha(object):
|
|||||||
'state': self.state_handler.state,
|
'state': self.state_handler.state,
|
||||||
'role': self.state_handler.role
|
'role': self.state_handler.role
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# following two lines are mainly necessary for consul, to avoid creation of master service
|
||||||
|
if data['role'] == 'master' and not self.is_leader():
|
||||||
|
data['role'] = 'promoted'
|
||||||
tags = self.get_effective_tags()
|
tags = self.get_effective_tags()
|
||||||
if tags:
|
if tags:
|
||||||
data['tags'] = tags
|
data['tags'] = tags
|
||||||
@@ -165,14 +217,24 @@ class Ha(object):
|
|||||||
self._async_executor.schedule('bootstrap {0}'.format(msg))
|
self._async_executor.schedule('bootstrap {0}'.format(msg))
|
||||||
self._async_executor.run_async(self.clone, args=(clone_member, msg))
|
self._async_executor.run_async(self.clone, args=(clone_member, msg))
|
||||||
return 'trying to bootstrap {0}'.format(msg)
|
return 'trying to bootstrap {0}'.format(msg)
|
||||||
|
|
||||||
# no initialize key and node is allowed to be master and has 'bootstrap' section in a configuration file
|
# no initialize key and node is allowed to be master and has 'bootstrap' section in a configuration file
|
||||||
elif self.cluster.initialize is None and not self.patroni.nofailover and 'bootstrap' in self.patroni.config:
|
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
|
if self.dcs.initialize(create_new=True): # race for initialization
|
||||||
self.state_handler.bootstrapping = True
|
self.state_handler.bootstrapping = True
|
||||||
self._post_bootstrap_task = CriticalTask()
|
self._post_bootstrap_task = CriticalTask()
|
||||||
self._async_executor.schedule('bootstrap')
|
|
||||||
self._async_executor.run_async(self.state_handler.bootstrap, args=(self.patroni.config['bootstrap'],))
|
if self.is_standby_cluster():
|
||||||
return 'trying to bootstrap a new cluster'
|
self._async_executor.schedule('bootstrap_standby_leader')
|
||||||
|
self._async_executor.run_async(self.bootstrap_standby_leader)
|
||||||
|
return 'trying to bootstrap a new standby leader'
|
||||||
|
else:
|
||||||
|
self._async_executor.schedule('bootstrap')
|
||||||
|
self._async_executor.run_async(
|
||||||
|
self.state_handler.bootstrap,
|
||||||
|
args=(self.patroni.config['bootstrap'],)
|
||||||
|
)
|
||||||
|
return 'trying to bootstrap a new cluster'
|
||||||
else:
|
else:
|
||||||
return 'failed to acquire initialize lock'
|
return 'failed to acquire initialize lock'
|
||||||
else:
|
else:
|
||||||
@@ -183,16 +245,26 @@ class Ha(object):
|
|||||||
return 'trying to ' + msg
|
return 'trying to ' + msg
|
||||||
return 'waiting for leader to bootstrap'
|
return 'waiting for leader to bootstrap'
|
||||||
|
|
||||||
def _handle_rewind(self):
|
def bootstrap_standby_leader(self):
|
||||||
if self.state_handler.rewind_needed_and_possible(self.cluster.leader):
|
""" If we found 'standby' key in the configuration, we need to bootstrap
|
||||||
self._async_executor.schedule('running pg_rewind from ' + self.cluster.leader.name)
|
not a real master, but a 'standby leader', that will take base backup
|
||||||
self._async_executor.run_async(self.state_handler.rewind, (self.cluster.leader,))
|
from a remote master and start follow it.
|
||||||
return True
|
"""
|
||||||
|
clone_source = self.get_remote_master()
|
||||||
|
msg = 'clone from remote master {0}'.format(clone_source.conn_url)
|
||||||
|
result = self.clone(clone_source, msg)
|
||||||
|
self._post_bootstrap_task.complete(result)
|
||||||
|
if result:
|
||||||
|
self.state_handler.set_role('standby_leader')
|
||||||
|
|
||||||
def _start_crash_recovery(self, msg):
|
return result
|
||||||
self._async_executor.schedule(msg)
|
|
||||||
self._async_executor.run_async(self.state_handler.fix_cluster_state)
|
def _handle_rewind(self):
|
||||||
return msg
|
leader = self.get_remote_master() if self.is_standby_cluster() else self.cluster.leader
|
||||||
|
if self.state_handler.rewind_needed_and_possible(leader):
|
||||||
|
self._async_executor.schedule('running pg_rewind from ' + leader.name)
|
||||||
|
self._async_executor.run_async(self.state_handler.rewind, (leader,))
|
||||||
|
return True
|
||||||
|
|
||||||
def recover(self):
|
def recover(self):
|
||||||
# Postgres is not running and we will restart in standby mode. Watchdog is not needed until we promote.
|
# Postgres is not running and we will restart in standby mode. Watchdog is not needed until we promote.
|
||||||
@@ -214,30 +286,33 @@ class Ha(object):
|
|||||||
timeout = None
|
timeout = None
|
||||||
|
|
||||||
data = self.state_handler.controldata()
|
data = self.state_handler.controldata()
|
||||||
if data.get('Database cluster state') == 'in production' and not self._crash_recovery_executed and \
|
if data.get('Database cluster state') in ('in production', 'shutting down', 'in crash recovery') and \
|
||||||
(self.cluster.is_unlocked() or self.state_handler.can_rewind):
|
not self._crash_recovery_executed and (self.cluster.is_unlocked() or self.state_handler.can_rewind):
|
||||||
self._crash_recovery_executed = True
|
self._crash_recovery_executed = True
|
||||||
return self._start_crash_recovery('doing crash recovery in a single user mode')
|
self._async_executor.schedule('doing crash recovery in a single user mode')
|
||||||
|
self._async_executor.run_async(self.state_handler.fix_cluster_state)
|
||||||
|
return self._async_executor.scheduled_action
|
||||||
|
|
||||||
self.load_cluster_from_dcs()
|
self.load_cluster_from_dcs()
|
||||||
|
|
||||||
if self.has_lock():
|
if self.is_standby_cluster() or not self.has_lock():
|
||||||
msg = "starting as readonly because i had the session lock"
|
|
||||||
node_to_follow = None
|
|
||||||
else:
|
|
||||||
if not self.state_handler.rewind_executed:
|
if not self.state_handler.rewind_executed:
|
||||||
self.state_handler.trigger_check_diverged_lsn()
|
self.state_handler.trigger_check_diverged_lsn()
|
||||||
if self._handle_rewind():
|
if self._handle_rewind():
|
||||||
return self._async_executor.scheduled_action
|
return self._async_executor.scheduled_action
|
||||||
msg = "starting as a secondary"
|
|
||||||
node_to_follow = self._get_node_to_follow(self.cluster)
|
|
||||||
|
|
||||||
# once we already tried to start postgres but failed, single user mode is a rescue in this case
|
if self.has_lock(): # in standby cluster
|
||||||
if self.recovering and not self.state_handler.rewind_executed \
|
msg = "starting as a standby leader because i had the session lock"
|
||||||
and not self._crash_recovery_executed and self.state_handler.can_rewind \
|
node_to_follow = self._get_node_to_follow(self.cluster)
|
||||||
and data.get('Database cluster state') not in ('shut down', 'shut down in recovery'):
|
elif self.is_standby_cluster() and self.cluster.is_unlocked():
|
||||||
self.recovering = False
|
msg = "trying to follow a remote master because standby cluster is unhealthy"
|
||||||
return self._start_crash_recovery('fixing cluster state in a single user mode')
|
node_to_follow = self.get_remote_master()
|
||||||
|
else:
|
||||||
|
msg = "starting as a secondary"
|
||||||
|
node_to_follow = self._get_node_to_follow(self.cluster)
|
||||||
|
elif self.has_lock():
|
||||||
|
msg = "starting as readonly because i had the session lock"
|
||||||
|
node_to_follow = None
|
||||||
|
|
||||||
self.recovering = True
|
self.recovering = True
|
||||||
|
|
||||||
@@ -248,12 +323,18 @@ class Ha(object):
|
|||||||
def _get_node_to_follow(self, cluster):
|
def _get_node_to_follow(self, cluster):
|
||||||
# determine the node to follow. If replicatefrom tag is set,
|
# determine the node to follow. If replicatefrom tag is set,
|
||||||
# try to follow the node mentioned there, otherwise, follow the leader.
|
# try to follow the node mentioned there, otherwise, follow the leader.
|
||||||
if not self.patroni.replicatefrom or self.patroni.replicatefrom == self.state_handler.name:
|
is_leader = self.cluster.leader and self.state_handler.name == self.cluster.leader.name
|
||||||
node_to_follow = cluster.leader
|
|
||||||
else:
|
|
||||||
node_to_follow = cluster.get_member(self.patroni.replicatefrom)
|
|
||||||
|
|
||||||
return node_to_follow if node_to_follow and node_to_follow.name != self.state_handler.name else None
|
if self.is_standby_cluster() and (is_leader or self.cluster.is_unlocked()):
|
||||||
|
node_to_follow = self.get_remote_master()
|
||||||
|
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
|
||||||
|
|
||||||
|
return (node_to_follow if
|
||||||
|
node_to_follow and
|
||||||
|
node_to_follow.name != self.state_handler.name else None)
|
||||||
|
|
||||||
def follow(self, demote_reason, follow_reason, refresh=True):
|
def follow(self, demote_reason, follow_reason, refresh=True):
|
||||||
if refresh:
|
if refresh:
|
||||||
@@ -284,10 +365,10 @@ class Ha(object):
|
|||||||
return follow_reason
|
return follow_reason
|
||||||
|
|
||||||
def is_synchronous_mode(self):
|
def is_synchronous_mode(self):
|
||||||
return bool(self.cluster and self.cluster.is_synchronous_mode())
|
return self.check_mode('synchronous_mode')
|
||||||
|
|
||||||
def is_synchronous_mode_strict(self):
|
def is_synchronous_mode_strict(self):
|
||||||
return bool(self.cluster and self.cluster.is_synchronous_mode_strict())
|
return self.check_mode('synchronous_mode_strict')
|
||||||
|
|
||||||
def process_sync_replication(self):
|
def process_sync_replication(self):
|
||||||
"""Process synchronous standby beahvior.
|
"""Process synchronous standby beahvior.
|
||||||
@@ -393,6 +474,12 @@ class Ha(object):
|
|||||||
line.append(cluster_history[line[0]][3])
|
line.append(cluster_history[line[0]][3])
|
||||||
self.dcs.set_history_value(json.dumps(history, separators=(',', ':')))
|
self.dcs.set_history_value(json.dumps(history, separators=(',', ':')))
|
||||||
|
|
||||||
|
def enforce_follow_remote_master(self, message):
|
||||||
|
self.state_handler.set_role('standby_leader')
|
||||||
|
demote_reason = 'cannot be a real master in standby cluster'
|
||||||
|
|
||||||
|
return self.follow(demote_reason, message)
|
||||||
|
|
||||||
def enforce_master_role(self, message, promote_message):
|
def enforce_master_role(self, message, promote_message):
|
||||||
if not self.is_paused() and not self.watchdog.is_running and not self.watchdog.activate():
|
if not self.is_paused() and not self.watchdog.is_running and not self.watchdog.activate():
|
||||||
if self.state_handler.is_leader():
|
if self.state_handler.is_leader():
|
||||||
@@ -422,8 +509,10 @@ class Ha(object):
|
|||||||
return 'Postponing promotion because synchronous replication state was updated by somebody else'
|
return 'Postponing promotion because synchronous replication state was updated by somebody else'
|
||||||
self.state_handler.set_synchronous_standby('*' if self.is_synchronous_mode_strict() else None)
|
self.state_handler.set_synchronous_standby('*' if self.is_synchronous_mode_strict() else None)
|
||||||
if self.state_handler.role != 'master':
|
if self.state_handler.role != 'master':
|
||||||
|
self.set_leader_access_is_restricted(self.cluster.has_permanent_logical_slots(self.state_handler.name))
|
||||||
self._async_executor.schedule('promote')
|
self._async_executor.schedule('promote')
|
||||||
self._async_executor.run_async(self.state_handler.promote, args=(self.dcs.loop_wait,))
|
self._async_executor.run_async(self.state_handler.promote,
|
||||||
|
args=(self.dcs.loop_wait, self._leader_access_is_restricted))
|
||||||
return promote_message
|
return promote_message
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -437,7 +526,7 @@ class Ha(object):
|
|||||||
logger.info('Got response from %s %s: %s', member.name, member.api_url, response.content)
|
logger.info('Got response from %s %s: %s', member.name, member.api_url, response.content)
|
||||||
return _MemberStatus.from_api_response(member, response.json())
|
return _MemberStatus.from_api_response(member, response.json())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("request failed: GET %s (%s)", member.api_url, e)
|
logger.warning("Request failed to %s: GET %s (%s)", member.name, member.api_url, e)
|
||||||
return _MemberStatus.unknown(member)
|
return _MemberStatus.unknown(member)
|
||||||
|
|
||||||
def fetch_nodes_statuses(self, members):
|
def fetch_nodes_statuses(self, members):
|
||||||
@@ -595,16 +684,17 @@ class Ha(object):
|
|||||||
PostgreSQL as quickly as possible without regard for data durability. May only be called synchronously.
|
PostgreSQL as quickly as possible without regard for data durability. May only be called synchronously.
|
||||||
"""
|
"""
|
||||||
mode_control = {
|
mode_control = {
|
||||||
'offline': dict(stop='fast', checkpoint=False, release=False, offline=True, async=False),
|
'offline': dict(stop='fast', checkpoint=False, release=False, offline=True, async_req=False),
|
||||||
'graceful': dict(stop='fast', checkpoint=True, release=True, offline=False, async=False),
|
'graceful': dict(stop='fast', checkpoint=True, release=True, offline=False, async_req=False),
|
||||||
'immediate': dict(stop='immediate', checkpoint=False, release=True, offline=False, async=True),
|
'immediate': dict(stop='immediate', checkpoint=False, release=True, offline=False, async_req=True),
|
||||||
'immediate-nolock': dict(stop='immediate', checkpoint=False, release=False, offline=False, async=True),
|
'immediate-nolock': dict(stop='immediate', checkpoint=False, release=False, offline=False, async_req=True),
|
||||||
}[mode]
|
}[mode]
|
||||||
|
|
||||||
self.state_handler.trigger_check_diverged_lsn()
|
self.state_handler.trigger_check_diverged_lsn()
|
||||||
self.state_handler.stop(mode_control['stop'], checkpoint=mode_control['checkpoint'],
|
self.state_handler.stop(mode_control['stop'], checkpoint=mode_control['checkpoint'],
|
||||||
on_safepoint=self.watchdog.disable if self.watchdog.is_running else None)
|
on_safepoint=self.watchdog.disable if self.watchdog.is_running else None)
|
||||||
self.state_handler.set_role('demoted')
|
self.state_handler.set_role('demoted')
|
||||||
|
self.set_is_leader(False)
|
||||||
|
|
||||||
if mode_control['release']:
|
if mode_control['release']:
|
||||||
self.release_leader_key_voluntarily()
|
self.release_leader_key_voluntarily()
|
||||||
@@ -618,7 +708,7 @@ class Ha(object):
|
|||||||
# FIXME: with mode offline called from DCS exception handler and handle_long_action_in_progress
|
# FIXME: with mode offline called from DCS exception handler and handle_long_action_in_progress
|
||||||
# there could be an async action already running, calling follow from here will lead
|
# there could be an async action already running, calling follow from here will lead
|
||||||
# to racy state handler state updates.
|
# to racy state handler state updates.
|
||||||
if mode_control['async']:
|
if mode_control['async_req']:
|
||||||
self._async_executor.schedule('starting after demotion')
|
self._async_executor.schedule('starting after demotion')
|
||||||
self._async_executor.run_async(self.state_handler.follow, (node_to_follow,))
|
self._async_executor.run_async(self.state_handler.follow, (node_to_follow,))
|
||||||
else:
|
else:
|
||||||
@@ -721,8 +811,18 @@ class Ha(object):
|
|||||||
logger.info('Cleaning up failover key after acquiring leader lock...')
|
logger.info('Cleaning up failover key after acquiring leader lock...')
|
||||||
self.dcs.manual_failover('', '')
|
self.dcs.manual_failover('', '')
|
||||||
self.load_cluster_from_dcs()
|
self.load_cluster_from_dcs()
|
||||||
return self.enforce_master_role('acquired session lock as a leader',
|
|
||||||
'promoted self to leader by acquiring session lock')
|
if self.is_standby_cluster():
|
||||||
|
# standby leader disappeared, and this is a healthiest
|
||||||
|
# replica, so it should become a new standby leader.
|
||||||
|
# This imply that we need to start following a remote master
|
||||||
|
msg = 'promoted self to a standby leader because i had the session lock'
|
||||||
|
return self.enforce_follow_remote_master(msg)
|
||||||
|
else:
|
||||||
|
return self.enforce_master_role(
|
||||||
|
'acquired session lock as a leader',
|
||||||
|
'promoted self to leader by acquiring session lock'
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return self.follow('demoted self after trying and failing to obtain lock',
|
return self.follow('demoted self after trying and failing to obtain lock',
|
||||||
'following new leader after trying and failing to obtain lock')
|
'following new leader after trying and failing to obtain lock')
|
||||||
@@ -749,17 +849,33 @@ class Ha(object):
|
|||||||
self.dcs.reset_cluster()
|
self.dcs.reset_cluster()
|
||||||
return 'removed leader lock because postgres is not running as master'
|
return 'removed leader lock because postgres is not running as master'
|
||||||
|
|
||||||
|
if self.state_handler.is_leader() and self._leader_access_is_restricted:
|
||||||
|
self.state_handler.sync_replication_slots(self.cluster)
|
||||||
|
self.state_handler.call_nowait(ACTION_ON_ROLE_CHANGE)
|
||||||
|
self.set_leader_access_is_restricted(False)
|
||||||
|
|
||||||
if self.update_lock(True):
|
if self.update_lock(True):
|
||||||
msg = self.process_manual_failover_from_leader()
|
msg = self.process_manual_failover_from_leader()
|
||||||
if msg is not None:
|
if msg is not None:
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
return self.enforce_master_role('no action. i am the leader with the lock',
|
if self.is_standby_cluster():
|
||||||
'promoted self to leader because i had the session lock')
|
# in case of standby cluster we don't really need to
|
||||||
|
# enforce anything, since the leader is not a master.
|
||||||
|
# So just remind the role.
|
||||||
|
msg = 'no action. i am the standby leader with the lock'
|
||||||
|
return self.enforce_follow_remote_master(msg)
|
||||||
|
else:
|
||||||
|
return self.enforce_master_role(
|
||||||
|
'no action. i am the leader with the lock',
|
||||||
|
'promoted self to leader because i had the session lock'
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Either there is no connection to DCS or someone else acquired the lock
|
# Either there is no connection to DCS or someone else acquired the lock
|
||||||
logger.error('failed to update leader lock')
|
logger.error('failed to update leader lock')
|
||||||
if self.state_handler.is_leader():
|
if self.state_handler.is_leader():
|
||||||
|
if self.is_paused():
|
||||||
|
return 'continue to run as master after failing to update leader lock in DCS'
|
||||||
self.demote('immediate-nolock')
|
self.demote('immediate-nolock')
|
||||||
return 'demoted self because failed to update leader lock in DCS'
|
return 'demoted self because failed to update leader lock in DCS'
|
||||||
else:
|
else:
|
||||||
@@ -883,7 +999,8 @@ class Ha(object):
|
|||||||
|
|
||||||
def _do_reinitialize(self, cluster):
|
def _do_reinitialize(self, cluster):
|
||||||
self.state_handler.stop('immediate')
|
self.state_handler.stop('immediate')
|
||||||
self.state_handler.remove_data_directory()
|
# Commented redundant data directory cleanup here
|
||||||
|
# self.state_handler.remove_data_directory()
|
||||||
|
|
||||||
clone_member = self.cluster.get_clone_member(self.state_handler.name)
|
clone_member = self.cluster.get_clone_member(self.state_handler.name)
|
||||||
member_role = 'leader' if clone_member == self.cluster.leader else 'replica'
|
member_role = 'leader' if clone_member == self.cluster.leader else 'replica'
|
||||||
@@ -976,9 +1093,12 @@ class Ha(object):
|
|||||||
if not self.watchdog.activate():
|
if not self.watchdog.activate():
|
||||||
logger.error('Cancelling bootstrap because watchdog activation failed')
|
logger.error('Cancelling bootstrap because watchdog activation failed')
|
||||||
self.cancel_initialization()
|
self.cancel_initialization()
|
||||||
|
self.state_handler.sync_replication_slots(self.cluster)
|
||||||
self.dcs.take_leader()
|
self.dcs.take_leader()
|
||||||
|
self.set_is_leader(True)
|
||||||
self.state_handler.call_nowait(ACTION_ON_START)
|
self.state_handler.call_nowait(ACTION_ON_START)
|
||||||
self.load_cluster_from_dcs()
|
self.load_cluster_from_dcs()
|
||||||
|
|
||||||
return 'initialized a new cluster'
|
return 'initialized a new cluster'
|
||||||
|
|
||||||
def handle_starting_instance(self):
|
def handle_starting_instance(self):
|
||||||
@@ -1034,6 +1154,11 @@ class Ha(object):
|
|||||||
|
|
||||||
if self.is_paused():
|
if self.is_paused():
|
||||||
self.watchdog.disable()
|
self.watchdog.disable()
|
||||||
|
self._was_paused = True
|
||||||
|
else:
|
||||||
|
if self._was_paused:
|
||||||
|
self.state_handler.schedule_sanity_checks_after_pause()
|
||||||
|
self._was_paused = False
|
||||||
|
|
||||||
if not self.cluster.has_member(self.state_handler.name):
|
if not self.cluster.has_member(self.state_handler.name):
|
||||||
self.touch_member()
|
self.touch_member()
|
||||||
@@ -1079,9 +1204,20 @@ class Ha(object):
|
|||||||
return self.bootstrap() # new node
|
return self.bootstrap() # new node
|
||||||
# "bootstrap", but data directory is not empty
|
# "bootstrap", but data directory is not empty
|
||||||
elif not self.sysid_valid(self.cluster.initialize) and self.cluster.is_unlocked() and not self.is_paused():
|
elif not self.sysid_valid(self.cluster.initialize) and self.cluster.is_unlocked() and not self.is_paused():
|
||||||
|
if not self.state_handler.cb_called and self.state_handler.is_running() \
|
||||||
|
and not self.state_handler.is_leader():
|
||||||
|
self._join_aborted = True
|
||||||
|
logger.error('No initialize key in DCS and PostgreSQL is running as replica, aborting start')
|
||||||
|
logger.error('Please first start Patroni on the node running as master')
|
||||||
|
sys.exit(1)
|
||||||
self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
|
self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
|
||||||
else:
|
else:
|
||||||
# check if we are allowed to join
|
# check if we are allowed to join
|
||||||
|
data_sysid = self.state_handler.sysid
|
||||||
|
if not self.sysid_valid(data_sysid):
|
||||||
|
# data directory is not empty, but no valid sysid, cluster must be broken, suggest reinit
|
||||||
|
return "data dir for the cluster is not empty, but system ID is invalid; consider doing reinitalize"
|
||||||
|
|
||||||
if self.sysid_valid(self.cluster.initialize) and self.cluster.initialize != self.state_handler.sysid:
|
if self.sysid_valid(self.cluster.initialize) and self.cluster.initialize != self.state_handler.sysid:
|
||||||
logger.fatal("system ID mismatch, node %s belongs to a different cluster: %s != %s",
|
logger.fatal("system ID mismatch, node %s belongs to a different cluster: %s != %s",
|
||||||
self.state_handler.name, self.cluster.initialize, self.state_handler.sysid)
|
self.state_handler.name, self.cluster.initialize, self.state_handler.sysid)
|
||||||
@@ -1093,8 +1229,13 @@ class Ha(object):
|
|||||||
self.dcs.delete_leader()
|
self.dcs.delete_leader()
|
||||||
self.dcs.reset_cluster()
|
self.dcs.reset_cluster()
|
||||||
return 'removed leader lock because postgres is not running'
|
return 'removed leader lock because postgres is not running'
|
||||||
elif not (self.state_handler.rewind_executed or
|
# Normally we don't start Postgres in a paused state. We make an exception for the demoted primary
|
||||||
self.state_handler.need_rewind and self.state_handler.can_rewind):
|
# that needs to be started after it had been stopped by demote. When there is no need to call rewind
|
||||||
|
# the demote code follows through to starting Postgres right away, however, in the rewind case
|
||||||
|
# it returns from demote and reaches this point to start PostgreSQL again after rewind. In that
|
||||||
|
# case it makes no sense to continue to recover() unless rewind has finished successfully.
|
||||||
|
elif (self.state_handler.rewind_failed or
|
||||||
|
not (self.state_handler.need_rewind and self.state_handler.can_rewind)):
|
||||||
return 'postgres is not running'
|
return 'postgres is not running'
|
||||||
|
|
||||||
# try to start dead postgres
|
# try to start dead postgres
|
||||||
@@ -1111,11 +1252,11 @@ class Ha(object):
|
|||||||
# stops PostgreSQL, therefore, we only reload replication slots if no
|
# stops PostgreSQL, therefore, we only reload replication slots if no
|
||||||
# asynchronous processes are running (should be always the case for the master)
|
# asynchronous processes are running (should be always the case for the master)
|
||||||
if not self._async_executor.busy and not self.state_handler.is_starting():
|
if not self._async_executor.busy and not self.state_handler.is_starting():
|
||||||
|
self.state_handler.sync_replication_slots(self.cluster)
|
||||||
if not self.state_handler.cb_called:
|
if not self.state_handler.cb_called:
|
||||||
if not self.state_handler.is_leader():
|
if not self.state_handler.is_leader():
|
||||||
self.state_handler.trigger_check_diverged_lsn()
|
self.state_handler.trigger_check_diverged_lsn()
|
||||||
self.state_handler.call_nowait(ACTION_ON_START)
|
self.state_handler.call_nowait(ACTION_ON_START)
|
||||||
self.state_handler.sync_replication_slots(self.cluster)
|
|
||||||
except DCSError:
|
except DCSError:
|
||||||
dcs_failed = True
|
dcs_failed = True
|
||||||
logger.error('Error communicating with DCS')
|
logger.error('Error communicating with DCS')
|
||||||
@@ -1138,7 +1279,7 @@ class Ha(object):
|
|||||||
if self.is_paused():
|
if self.is_paused():
|
||||||
logger.info('Leader key is not deleted and Postgresql is not stopped due paused state')
|
logger.info('Leader key is not deleted and Postgresql is not stopped due paused state')
|
||||||
self.watchdog.disable()
|
self.watchdog.disable()
|
||||||
else:
|
elif not self._join_aborted:
|
||||||
# FIXME: If stop doesn't reach safepoint quickly enough keepalive is triggered. If shutdown checkpoint
|
# FIXME: If stop doesn't reach safepoint quickly enough keepalive is triggered. If shutdown checkpoint
|
||||||
# takes longer than ttl, then leader key is lost and replication might not have sent out all xlog.
|
# takes longer than ttl, then leader key is lost and replication might not have sent out all xlog.
|
||||||
# This might not be the desired behavior of users, as a graceful shutdown of the host can mean lost data.
|
# This might not be the desired behavior of users, as a graceful shutdown of the host can mean lost data.
|
||||||
@@ -1148,6 +1289,7 @@ class Ha(object):
|
|||||||
if not self.state_handler.is_running():
|
if not self.state_handler.is_running():
|
||||||
if self.has_lock():
|
if self.has_lock():
|
||||||
self.dcs.delete_leader()
|
self.dcs.delete_leader()
|
||||||
|
self.touch_member()
|
||||||
else:
|
else:
|
||||||
# XXX: what about when Patroni is started as the wrong user that has access to the watchdog device
|
# XXX: what about when Patroni is started as the wrong user that has access to the watchdog device
|
||||||
# but cannot shut down PostgreSQL. Root would be the obvious example. Would be nice to not kill the
|
# but cannot shut down PostgreSQL. Root would be the obvious example. Would be nice to not kill the
|
||||||
@@ -1171,3 +1313,26 @@ class Ha(object):
|
|||||||
no "active" leader watch request in progress.
|
no "active" leader watch request in progress.
|
||||||
This usually happens on the master or if the node is running async action"""
|
This usually happens on the master or if the node is running async action"""
|
||||||
self.dcs.event.set()
|
self.dcs.event.set()
|
||||||
|
|
||||||
|
def get_remote_master(self):
|
||||||
|
""" In case of standby cluster this will tel us from which remote
|
||||||
|
master to stream. Config can be both patroni config or
|
||||||
|
cluster.config.data
|
||||||
|
"""
|
||||||
|
cluster_params = self.get_standby_cluster_config()
|
||||||
|
|
||||||
|
if cluster_params:
|
||||||
|
unique_name = 'remote_master:{}'.format(uuid.uuid1())
|
||||||
|
data = {
|
||||||
|
'conn_kwargs': {
|
||||||
|
"host": cluster_params.get('host'),
|
||||||
|
"port": cluster_params.get('port'),
|
||||||
|
},
|
||||||
|
'no_replication_slot': 'primary_slot_name' not in cluster_params,
|
||||||
|
}
|
||||||
|
data.update({
|
||||||
|
k: v for k, v in cluster_params.items()
|
||||||
|
if k in RemoteMember.allowed_keys()
|
||||||
|
})
|
||||||
|
|
||||||
|
return RemoteMember(unique_name, data)
|
||||||
|
|||||||
+299
-168
@@ -15,10 +15,13 @@ from patroni.callback_executor import CallbackExecutor
|
|||||||
from patroni.exceptions import PostgresConnectionException, PostgresException
|
from patroni.exceptions import PostgresConnectionException, PostgresException
|
||||||
from patroni.utils import compare_values, parse_bool, parse_int, Retry, RetryFailedError, polling_loop, split_host_port
|
from patroni.utils import compare_values, parse_bool, parse_int, Retry, RetryFailedError, polling_loop, split_host_port
|
||||||
from patroni.postmaster import PostmasterProcess
|
from patroni.postmaster import PostmasterProcess
|
||||||
|
from patroni.dcs import slot_name_from_member_name, RemoteMember
|
||||||
|
from requests.structures import CaseInsensitiveDict
|
||||||
from six import string_types
|
from six import string_types
|
||||||
from six.moves.urllib.parse import quote_plus
|
from six.moves.urllib.parse import quote_plus
|
||||||
from threading import current_thread, Lock
|
from threading import current_thread, Lock
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ACTION_ON_START = "on_start"
|
ACTION_ON_START = "on_start"
|
||||||
@@ -34,14 +37,16 @@ STATE_UNKNOWN = 'unknown'
|
|||||||
|
|
||||||
STOP_POLLING_INTERVAL = 1
|
STOP_POLLING_INTERVAL = 1
|
||||||
REWIND_STATUS = type('Enum', (), {'INITIAL': 0, 'CHECK': 1, 'NEED': 2, 'NOT_NEED': 3, 'SUCCESS': 4, 'FAILED': 5})
|
REWIND_STATUS = type('Enum', (), {'INITIAL': 0, 'CHECK': 1, 'NEED': 2, 'NOT_NEED': 3, 'SUCCESS': 4, 'FAILED': 5})
|
||||||
sync_standby_name_re = re.compile('^[A-Za-z_][A-Za-z_0-9\$]*$')
|
sync_standby_name_re = re.compile(r'^[A-Za-z_][A-Za-z_0-9\$]*$')
|
||||||
|
|
||||||
cluster_info_query = ("SELECT CASE WHEN pg_is_in_recovery() THEN 0 "
|
cluster_info_query = ("SELECT CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0 "
|
||||||
"ELSE ('x' || SUBSTR(pg_{0}file_name(pg_current_{0}_{1}()), 1, 8))::bit(32)::int END, "
|
"ELSE ('x' || pg_catalog.substr(pg_catalog.pg_{0}file_name("
|
||||||
"CASE WHEN pg_is_in_recovery() THEN GREATEST("
|
"pg_catalog.pg_current_{0}_{1}()), 1, 8))::bit(32)::int END, "
|
||||||
" pg_{0}_{1}_diff(COALESCE(pg_last_{0}_receive_{1}(), '0/0'), '0/0')::bigint,"
|
"CASE WHEN pg_catalog.pg_is_in_recovery() THEN GREATEST("
|
||||||
" pg_{0}_{1}_diff(pg_last_{0}_replay_{1}(), '0/0')::bigint)"
|
" pg_catalog.pg_{0}_{1}_diff(COALESCE("
|
||||||
"ELSE pg_{0}_{1}_diff(pg_current_{0}_{1}(), '0/0')::bigint END")
|
"pg_catalog.pg_last_{0}_receive_{1}(), '0/0'), '0/0')::bigint,"
|
||||||
|
" pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), '0/0')::bigint)"
|
||||||
|
"ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), '0/0')::bigint END")
|
||||||
|
|
||||||
|
|
||||||
def quote_ident(value):
|
def quote_ident(value):
|
||||||
@@ -49,22 +54,6 @@ def quote_ident(value):
|
|||||||
return value if sync_standby_name_re.match(value) else '"' + value + '"'
|
return value if sync_standby_name_re.match(value) else '"' + value + '"'
|
||||||
|
|
||||||
|
|
||||||
def slot_name_from_member_name(member_name):
|
|
||||||
"""Translate member name to valid PostgreSQL slot name.
|
|
||||||
|
|
||||||
PostgreSQL replication slot names must be valid PostgreSQL names. This function maps the wider space of
|
|
||||||
member names to valid PostgreSQL names. Names are lowercased, dashes and periods common in hostnames
|
|
||||||
are replaced with underscores, other characters are encoded as their unicode codepoint. Name is truncated
|
|
||||||
to 64 characters. Multiple different member names may map to a single slot name."""
|
|
||||||
|
|
||||||
def replace_char(match):
|
|
||||||
c = match.group(0)
|
|
||||||
return '_' if c in '-.' else "u{:04d}".format(ord(c))
|
|
||||||
|
|
||||||
slot_name = re.sub('[^a-z0-9_]', replace_char, member_name.lower())
|
|
||||||
return slot_name[0:63]
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def null_context():
|
def null_context():
|
||||||
yield
|
yield
|
||||||
@@ -86,7 +75,7 @@ class Postgresql(object):
|
|||||||
# default_value -- some sane default value
|
# default_value -- some sane default value
|
||||||
# check_function -- if the new value is not correct must return `!False`
|
# check_function -- if the new value is not correct must return `!False`
|
||||||
# min_version -- major version of PostgreSQL when parameter was introduced
|
# min_version -- major version of PostgreSQL when parameter was introduced
|
||||||
CMDLINE_OPTIONS = {
|
CMDLINE_OPTIONS = CaseInsensitiveDict({
|
||||||
'listen_addresses': (None, lambda _: False, 90100),
|
'listen_addresses': (None, lambda _: False, 90100),
|
||||||
'port': (None, lambda _: False, 90100),
|
'port': (None, lambda _: False, 90100),
|
||||||
'cluster_name': (None, lambda _: False, 90500),
|
'cluster_name': (None, lambda _: False, 90500),
|
||||||
@@ -101,7 +90,7 @@ class Postgresql(object):
|
|||||||
'max_replication_slots': (10, lambda v: int(v) >= 10, 90400),
|
'max_replication_slots': (10, lambda v: int(v) >= 10, 90400),
|
||||||
'max_worker_processes': (8, lambda v: int(v) >= 8, 90400),
|
'max_worker_processes': (8, lambda v: int(v) >= 8, 90400),
|
||||||
'wal_log_hints': ('on', lambda _: False, 90400)
|
'wal_log_hints': ('on', lambda _: False, 90400)
|
||||||
}
|
})
|
||||||
|
|
||||||
_CONFIG_WARNING_HEADER = '# Do not edit this file manually!\n# It will be overwritten by Patroni!\n'
|
_CONFIG_WARNING_HEADER = '# Do not edit this file manually!\n# It will be overwritten by Patroni!\n'
|
||||||
|
|
||||||
@@ -140,7 +129,6 @@ class Postgresql(object):
|
|||||||
self._postgresql_base_conf = os.path.join(self._config_dir, self._postgresql_base_conf_name)
|
self._postgresql_base_conf = os.path.join(self._config_dir, self._postgresql_base_conf_name)
|
||||||
self._pg_hba_conf = os.path.join(self._config_dir, 'pg_hba.conf')
|
self._pg_hba_conf = os.path.join(self._config_dir, 'pg_hba.conf')
|
||||||
self._recovery_conf = os.path.join(self._data_dir, 'recovery.conf')
|
self._recovery_conf = os.path.join(self._data_dir, 'recovery.conf')
|
||||||
self._postmaster_pid = os.path.join(self._data_dir, 'postmaster.pid')
|
|
||||||
self._trigger_file = config.get('recovery_conf', {}).get('trigger_file') or 'promote'
|
self._trigger_file = config.get('recovery_conf', {}).get('trigger_file') or 'promote'
|
||||||
self._trigger_file = os.path.abspath(os.path.join(self._data_dir, self._trigger_file))
|
self._trigger_file = os.path.abspath(os.path.join(self._data_dir, self._trigger_file))
|
||||||
|
|
||||||
@@ -152,7 +140,7 @@ class Postgresql(object):
|
|||||||
self._connection = None
|
self._connection = None
|
||||||
self._cursor_holder = None
|
self._cursor_holder = None
|
||||||
self._sysid = None
|
self._sysid = None
|
||||||
self._replication_slots = [] # list of already existing replication slots
|
self._replication_slots = {} # already existing replication slots
|
||||||
self.retry = Retry(max_tries=-1, deadline=config['retry_timeout']/2.0, max_delay=1,
|
self.retry = Retry(max_tries=-1, deadline=config['retry_timeout']/2.0, max_delay=1,
|
||||||
retry_exceptions=PostgresConnectionException)
|
retry_exceptions=PostgresConnectionException)
|
||||||
|
|
||||||
@@ -179,6 +167,13 @@ class Postgresql(object):
|
|||||||
self._write_postgresql_conf() # we are "joining" already running postgres
|
self._write_postgresql_conf() # we are "joining" already running postgres
|
||||||
if self._replace_pg_hba():
|
if self._replace_pg_hba():
|
||||||
self.reload()
|
self.reload()
|
||||||
|
elif self.role == 'master':
|
||||||
|
self.set_role('demoted')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _create_replica_methods(self):
|
||||||
|
return (self.config.get('create_replica_methods', []) or
|
||||||
|
self.config.get('create_replica_method', []))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _configuration_to_save(self):
|
def _configuration_to_save(self):
|
||||||
@@ -241,11 +236,9 @@ class Postgresql(object):
|
|||||||
parameters['synchronous_standby_names'] = self._synchronous_standby_names
|
parameters['synchronous_standby_names'] = self._synchronous_standby_names
|
||||||
if self._major_version >= 90600 and parameters['wal_level'] == 'hot_standby':
|
if self._major_version >= 90600 and parameters['wal_level'] == 'hot_standby':
|
||||||
parameters['wal_level'] = 'replica'
|
parameters['wal_level'] = 'replica'
|
||||||
ret = {k: v for k, v in parameters.items() if not self._major_version or
|
ret = CaseInsensitiveDict({k: v for k, v in parameters.items() if not self._major_version or
|
||||||
self._major_version >= self.CMDLINE_OPTIONS.get(k, (0, 1, 90100))[2]}
|
self._major_version >= self.CMDLINE_OPTIONS.get(k, (0, 1, 90100))[2]})
|
||||||
for k in ('hba_file', 'ident_file'):
|
ret.update({k: os.path.join(self._config_dir, ret[k]) for k in ('hba_file', 'ident_file') if k in ret})
|
||||||
if k in ret:
|
|
||||||
ret[k] = os.path.join(self._config_dir, ret[k])
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def resolve_connection_addresses(self):
|
def resolve_connection_addresses(self):
|
||||||
@@ -310,16 +303,16 @@ class Postgresql(object):
|
|||||||
|
|
||||||
conf_changed = hba_changed = local_connection_address_changed = pending_restart = False
|
conf_changed = hba_changed = local_connection_address_changed = pending_restart = False
|
||||||
if self.state == 'running':
|
if self.state == 'running':
|
||||||
changes = {p: v for p, v in server_parameters.items() if '.' not in p}
|
changes = CaseInsensitiveDict({p: v for p, v in server_parameters.items() if '.' not in p})
|
||||||
changes.update({p: None for p, v in self._server_parameters.items() if not ('.' in p or p in changes)})
|
changes.update({p: None for p in self._server_parameters.keys() if not ('.' in p or p in changes)})
|
||||||
if changes:
|
if changes:
|
||||||
if 'wal_segment_size' not in changes:
|
if 'wal_segment_size' not in changes:
|
||||||
changes['wal_segment_size'] = '16384kB'
|
changes['wal_segment_size'] = '16384kB'
|
||||||
# XXX: query can raise an exception
|
# XXX: query can raise an exception
|
||||||
for r in self.query("""SELECT name, setting, unit, vartype, context
|
for r in self.query("""SELECT name, setting, unit, vartype, context
|
||||||
FROM pg_settings
|
FROM pg_catalog.pg_settings
|
||||||
WHERE name IN (""" + ', '.join(['%s'] * len(changes)) + """)
|
WHERE pg_catalog.lower(name) IN (""" + ', '.join(['%s'] * len(changes)) + """)
|
||||||
ORDER BY 1 DESC""", *(list(changes.keys()))):
|
ORDER BY 1 DESC""", *(k.lower() for k in changes.keys())):
|
||||||
if r[4] == 'internal':
|
if r[4] == 'internal':
|
||||||
if r[0] == 'wal_segment_size':
|
if r[0] == 'wal_segment_size':
|
||||||
server_parameters.pop(r[0], None)
|
server_parameters.pop(r[0], None)
|
||||||
@@ -332,10 +325,12 @@ class Postgresql(object):
|
|||||||
if new_value is None or not compare_values(r[3], unit, r[1], new_value):
|
if new_value is None or not compare_values(r[3], unit, r[1], new_value):
|
||||||
if r[4] == 'postmaster':
|
if r[4] == 'postmaster':
|
||||||
pending_restart = True
|
pending_restart = True
|
||||||
|
logger.info('Changed %s from %s to %s (restart required)', r[0], r[1], new_value)
|
||||||
if config.get('use_unix_socket') and r[0] == 'unix_socket_directories'\
|
if config.get('use_unix_socket') and r[0] == 'unix_socket_directories'\
|
||||||
or r[0] in ('listen_addresses', 'port'):
|
or r[0] in ('listen_addresses', 'port'):
|
||||||
local_connection_address_changed = True
|
local_connection_address_changed = True
|
||||||
else:
|
else:
|
||||||
|
logger.info('Changed %s from %s to %s', r[0], r[1], new_value)
|
||||||
conf_changed = True
|
conf_changed = True
|
||||||
for param in changes:
|
for param in changes:
|
||||||
if param in server_parameters:
|
if param in server_parameters:
|
||||||
@@ -346,11 +341,13 @@ class Postgresql(object):
|
|||||||
if not conf_changed:
|
if not conf_changed:
|
||||||
for p, v in server_parameters.items():
|
for p, v in server_parameters.items():
|
||||||
if '.' in p and (p not in self._server_parameters or str(v) != str(self._server_parameters[p])):
|
if '.' in p and (p not in self._server_parameters or str(v) != str(self._server_parameters[p])):
|
||||||
|
logger.info('Changed %s from %s to %s', p, self._server_parameters.get(p), v)
|
||||||
conf_changed = True
|
conf_changed = True
|
||||||
break
|
break
|
||||||
if not conf_changed:
|
if not conf_changed:
|
||||||
for p, v in self._server_parameters.items():
|
for p, v in self._server_parameters.items():
|
||||||
if '.' in p and (p not in server_parameters or str(v) != str(server_parameters[p])):
|
if '.' in p and (p not in server_parameters or str(v) != str(server_parameters[p])):
|
||||||
|
logger.info('Changed %s from %s to %s', p, v, server_parameters.get(p))
|
||||||
conf_changed = True
|
conf_changed = True
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -372,7 +369,10 @@ class Postgresql(object):
|
|||||||
self._replace_pg_hba()
|
self._replace_pg_hba()
|
||||||
|
|
||||||
if conf_changed or hba_changed:
|
if conf_changed or hba_changed:
|
||||||
|
logger.info('PostgreSQL configuration items changed, reloading configuration.')
|
||||||
self.reload()
|
self.reload()
|
||||||
|
elif not pending_restart:
|
||||||
|
logger.info('No PostgreSQL configuration items changed, nothing to reload.')
|
||||||
|
|
||||||
self._is_leader_retry.deadline = self.retry.deadline = config['retry_timeout']/2.0
|
self._is_leader_retry.deadline = self.retry.deadline = config['retry_timeout']/2.0
|
||||||
|
|
||||||
@@ -405,7 +405,7 @@ class Postgresql(object):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def sysid(self):
|
def sysid(self):
|
||||||
if not self._sysid:
|
if not self._sysid and not self.bootstrapping:
|
||||||
data = self.controldata()
|
data = self.controldata()
|
||||||
self._sysid = data.get('Database system identifier', "")
|
self._sysid = data.get('Database system identifier', "")
|
||||||
return self._sysid
|
return self._sysid
|
||||||
@@ -500,28 +500,44 @@ class Postgresql(object):
|
|||||||
return not os.path.exists(self._data_dir) or os.listdir(self._data_dir) == []
|
return not os.path.exists(self._data_dir) or os.listdir(self._data_dir) == []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def initdb_allowed_option(name):
|
def process_user_options(tool, options, not_allowed_options, error_handler):
|
||||||
if name in ['pgdata', 'nosync', 'pwfile', 'sync-only']:
|
user_options = []
|
||||||
raise Exception('{0} option for initdb is not allowed'.format(name))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_initdb_options(self, config):
|
def option_is_allowed(name):
|
||||||
options = []
|
ret = name not in not_allowed_options
|
||||||
for o in config:
|
if not ret:
|
||||||
if isinstance(o, string_types) and self.initdb_allowed_option(o):
|
error_handler('{0} option for {1} is not allowed'.format(name, tool))
|
||||||
options.append('--{0}'.format(o))
|
return ret
|
||||||
elif isinstance(o, dict):
|
|
||||||
keys = list(o.keys())
|
if isinstance(options, dict):
|
||||||
if len(keys) != 1 or not isinstance(keys[0], string_types) or not self.initdb_allowed_option(keys[0]):
|
for k, v in options.items():
|
||||||
raise Exception('Invalid option: {0}'.format(o))
|
if k and v:
|
||||||
options.append('--{0}={1}'.format(keys[0], o[keys[0]]))
|
user_options.append('--{0}={1}'.format(k, v))
|
||||||
else:
|
elif isinstance(options, list):
|
||||||
raise Exception('Unknown type of initdb option: {0}'.format(o))
|
for opt in options:
|
||||||
return options
|
if isinstance(opt, string_types) and option_is_allowed(opt):
|
||||||
|
user_options.append('--{0}'.format(opt))
|
||||||
|
elif isinstance(opt, dict):
|
||||||
|
keys = list(opt.keys())
|
||||||
|
if len(keys) != 1 or not isinstance(opt[keys[0]], string_types) or not option_is_allowed(keys[0]):
|
||||||
|
error_handler('Error when parsing {0} key-value option {1}: only one key-value is allowed'
|
||||||
|
' and value should be a string'.format(tool, opt[keys[0]]))
|
||||||
|
user_options.append('--{0}={1}'.format(keys[0], opt[keys[0]]))
|
||||||
|
else:
|
||||||
|
error_handler('Error when parsing {0} option {1}: value should be string value'
|
||||||
|
' or a single key-value pair'.format(tool, opt))
|
||||||
|
else:
|
||||||
|
error_handler('{0} options must be list ot dict'.format(tool))
|
||||||
|
return user_options
|
||||||
|
|
||||||
def _initdb(self, config):
|
def _initdb(self, config):
|
||||||
self.set_state('initalizing new cluster')
|
self.set_state('initalizing new cluster')
|
||||||
options = self.get_initdb_options(config.get('initdb') or [])
|
not_allowed_options = ('pgdata', 'nosync', 'pwfile', 'sync-only', 'version')
|
||||||
|
|
||||||
|
def error_handler(e):
|
||||||
|
raise Exception(e)
|
||||||
|
|
||||||
|
options = self.process_user_options('initdb', config.get('initdb') or [], not_allowed_options, error_handler)
|
||||||
pwfile = None
|
pwfile = None
|
||||||
|
|
||||||
if self._superuser:
|
if self._superuser:
|
||||||
@@ -537,16 +553,12 @@ class Postgresql(object):
|
|||||||
ret = self.pg_ctl('initdb', *options)
|
ret = self.pg_ctl('initdb', *options)
|
||||||
if pwfile:
|
if pwfile:
|
||||||
os.remove(pwfile)
|
os.remove(pwfile)
|
||||||
if ret:
|
if not ret:
|
||||||
if not self._server_parameters.get('hba_file') and not self.config.get('pg_hba'):
|
|
||||||
self.write_pg_hba(config.get('pg_hba', []))
|
|
||||||
self._major_version = self.get_major_version()
|
|
||||||
self._server_parameters = self.get_server_parameters(self.config)
|
|
||||||
else:
|
|
||||||
self.set_state('initdb failed')
|
self.set_state('initdb failed')
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def _custom_bootstrap(self, config):
|
def _custom_bootstrap(self, config):
|
||||||
|
self.set_state('running custom bootstrap script')
|
||||||
params = ['--scope=' + self.scope, '--datadir=' + self._data_dir]
|
params = ['--scope=' + self.scope, '--datadir=' + self._data_dir]
|
||||||
try:
|
try:
|
||||||
logger.info('Running custom bootstrap script: %s', config['command'])
|
logger.info('Running custom bootstrap script: %s', config['command'])
|
||||||
@@ -557,11 +569,11 @@ class Postgresql(object):
|
|||||||
logger.exception('Exception during custom bootstrap')
|
logger.exception('Exception during custom bootstrap')
|
||||||
return False
|
return False
|
||||||
self._post_restore()
|
self._post_restore()
|
||||||
self.save_configuration_files()
|
|
||||||
|
|
||||||
if 'recovery_conf' in config:
|
if 'recovery_conf' in config:
|
||||||
self.write_recovery_conf(config['recovery_conf'])
|
self.write_recovery_conf(config['recovery_conf'])
|
||||||
elif os.path.isfile(self._recovery_conf) or os.path.islink(self._recovery_conf):
|
elif (os.path.isfile(self._recovery_conf) or os.path.islink(self._recovery_conf)) and \
|
||||||
|
not config.get('keep_existing_recovery_conf'):
|
||||||
os.unlink(self._recovery_conf)
|
os.unlink(self._recovery_conf)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -614,7 +626,8 @@ class Postgresql(object):
|
|||||||
return os.environ.copy()
|
return os.environ.copy()
|
||||||
|
|
||||||
with open(self._pgpass, 'w') as f:
|
with open(self._pgpass, 'w') as f:
|
||||||
os.fchmod(f.fileno(), 0o600)
|
if os.name != 'nt':
|
||||||
|
os.fchmod(f.fileno(), 0o600)
|
||||||
f.write('{host}:{port}:*:{user}:{password}\n'.format(**record))
|
f.write('{host}:{port}:*:{user}:{password}\n'.format(**record))
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
@@ -628,7 +641,7 @@ class Postgresql(object):
|
|||||||
""" go through the replication methods to see if there are ones
|
""" go through the replication methods to see if there are ones
|
||||||
that does not require a working replication connection.
|
that does not require a working replication connection.
|
||||||
"""
|
"""
|
||||||
replica_methods = self.config.get('create_replica_method', [])
|
replica_methods = self._create_replica_methods
|
||||||
return any(self.replica_method_can_work_without_replication_connection(method) for method in replica_methods)
|
return any(self.replica_method_can_work_without_replication_connection(method) for method in replica_methods)
|
||||||
|
|
||||||
def create_replica(self, clone_member):
|
def create_replica(self, clone_member):
|
||||||
@@ -641,9 +654,17 @@ class Postgresql(object):
|
|||||||
self.set_state('creating replica')
|
self.set_state('creating replica')
|
||||||
self._sysid = None
|
self._sysid = None
|
||||||
|
|
||||||
# get list of replica methods from config.
|
is_remote_master = isinstance(clone_member, RemoteMember)
|
||||||
# If there is no configuration key, or no value is specified, use basebackup
|
create_replica_methods = is_remote_master and clone_member.create_replica_methods
|
||||||
replica_methods = self.config.get('create_replica_method') or ['basebackup']
|
|
||||||
|
# get list of replica methods either from clone member or from
|
||||||
|
# the config. If there is no configuration key, or no value is
|
||||||
|
# specified, use basebackup
|
||||||
|
replica_methods = (
|
||||||
|
create_replica_methods
|
||||||
|
or self._create_replica_methods
|
||||||
|
or ['basebackup']
|
||||||
|
)
|
||||||
|
|
||||||
if clone_member and clone_member.conn_url:
|
if clone_member and clone_member.conn_url:
|
||||||
r = clone_member.conn_kwargs(self._replication)
|
r = clone_member.conn_kwargs(self._replication)
|
||||||
@@ -665,30 +686,36 @@ class Postgresql(object):
|
|||||||
break
|
break
|
||||||
# if the method is basebackup, then use the built-in
|
# if the method is basebackup, then use the built-in
|
||||||
if replica_method == "basebackup":
|
if replica_method == "basebackup":
|
||||||
ret = self.basebackup(connstring, env)
|
ret = self.basebackup(connstring, env, self.config.get(replica_method, {}))
|
||||||
if ret == 0:
|
if ret == 0:
|
||||||
logger.info("replica has been created using basebackup")
|
logger.info("replica has been created using basebackup")
|
||||||
# if basebackup succeeds, exit with success
|
# if basebackup succeeds, exit with success
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
if not self.data_directory_empty():
|
if not self.data_directory_empty() and not self.config.get(replica_method, {}).get('keep_data', False):
|
||||||
self.remove_data_directory()
|
self.remove_data_directory()
|
||||||
|
else:
|
||||||
|
logger.info('Leaving data directory uncleaned')
|
||||||
|
|
||||||
cmd = replica_method
|
cmd = replica_method
|
||||||
method_config = {}
|
method_config = {}
|
||||||
# user-defined method; check for configuration
|
# user-defined method; check for configuration
|
||||||
# not required, actually
|
# not required, actually
|
||||||
if replica_method in self.config:
|
if self.config.get(replica_method, {}):
|
||||||
method_config = self.config[replica_method].copy()
|
method_config = self.config[replica_method].copy()
|
||||||
# look to see if the user has supplied a full command path
|
# look to see if the user has supplied a full command path
|
||||||
# if not, use the method name as the command
|
# if not, use the method name as the command
|
||||||
cmd = method_config.pop('command', cmd)
|
cmd = method_config.pop('command', cmd)
|
||||||
|
|
||||||
# add the default parameters
|
# add the default parameters
|
||||||
method_config.update({"scope": self.scope,
|
if not method_config.get('no_params', False):
|
||||||
"role": "replica",
|
method_config.update({"scope": self.scope,
|
||||||
"datadir": self._data_dir,
|
"role": "replica",
|
||||||
"connstring": connstring})
|
"datadir": self._data_dir,
|
||||||
|
"connstring": connstring})
|
||||||
|
else:
|
||||||
|
for param in ('no_params', 'no_master', 'keep_data'):
|
||||||
|
method_config.pop(param, None)
|
||||||
params = ["--{0}={1}".format(arg, val) for arg, val in method_config.items()]
|
params = ["--{0}={1}".format(arg, val) for arg, val in method_config.items()]
|
||||||
try:
|
try:
|
||||||
# call script with the full set of parameters
|
# call script with the full set of parameters
|
||||||
@@ -731,27 +758,18 @@ class Postgresql(object):
|
|||||||
|
|
||||||
def is_running(self):
|
def is_running(self):
|
||||||
"""Returns PostmasterProcess if one is running on the data directory or None. If most recently seen process
|
"""Returns PostmasterProcess if one is running on the data directory or None. If most recently seen process
|
||||||
is running udpates the cached process based on pid file."""
|
is running updates the cached process based on pid file."""
|
||||||
if self._postmaster_proc:
|
if self._postmaster_proc:
|
||||||
if self._postmaster_proc.is_running():
|
if self._postmaster_proc.is_running():
|
||||||
return self._postmaster_proc
|
return self._postmaster_proc
|
||||||
self._postmaster_proc = None
|
self._postmaster_proc = None
|
||||||
|
|
||||||
self._postmaster_proc = PostmasterProcess.from_pidfile(self._read_pid_file())
|
# we noticed that postgres was restarted, force syncing of replication
|
||||||
|
self._schedule_load_slots = self.use_slots
|
||||||
|
|
||||||
|
self._postmaster_proc = PostmasterProcess.from_pidfile(self._data_dir)
|
||||||
return self._postmaster_proc
|
return self._postmaster_proc
|
||||||
|
|
||||||
def _read_pid_file(self):
|
|
||||||
"""Reads and parses postmaster.pid from the data directory
|
|
||||||
|
|
||||||
:returns dictionary of values if successful, empty dictionary otherwise
|
|
||||||
"""
|
|
||||||
pid_line_names = ['pid', 'data_dir', 'start_time', 'port', 'socket_dir', 'listen_addr', 'shmem_key']
|
|
||||||
try:
|
|
||||||
with open(self._postmaster_pid) as f:
|
|
||||||
return {name: line.rstrip("\n") for name, line in zip(pid_line_names, f)}
|
|
||||||
except IOError:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cb_called(self):
|
def cb_called(self):
|
||||||
return self.__cb_called
|
return self.__cb_called
|
||||||
@@ -817,6 +835,36 @@ class Postgresql(object):
|
|||||||
logger.warning("Timed out waiting for PostgreSQL to start")
|
logger.warning("Timed out waiting for PostgreSQL to start")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _build_effective_configuration(self):
|
||||||
|
"""It might happen that the current value of one (or more) below parameters stored in
|
||||||
|
the controldata is higher than the value stored in the global cluster configuration.
|
||||||
|
|
||||||
|
Example: max_connections in global configuration is 100, but in controldata
|
||||||
|
`Current max_connections setting: 200`. If we try to start postgres with
|
||||||
|
max_connections=100, it will immediately exit.
|
||||||
|
As a workaround we will start it with the values from controldata and set `pending_restart`
|
||||||
|
to true as an indicator that current values of parameters are not matching expectations."""
|
||||||
|
|
||||||
|
OPTIONS_MAPPING = {
|
||||||
|
'max_connections': 'max_connections setting',
|
||||||
|
'max_prepared_transactions': 'max_prepared_xacts setting',
|
||||||
|
'max_locks_per_transaction': 'max_locks_per_xact setting'
|
||||||
|
}
|
||||||
|
|
||||||
|
if self._major_version >= 90400:
|
||||||
|
OPTIONS_MAPPING['max_worker_processes'] = 'max_worker_processes setting'
|
||||||
|
|
||||||
|
data = self.controldata()
|
||||||
|
effective_configuration = self._server_parameters.copy()
|
||||||
|
|
||||||
|
for name, cname in OPTIONS_MAPPING.items():
|
||||||
|
value = parse_int(effective_configuration[name])
|
||||||
|
cvalue = parse_int(data[cname])
|
||||||
|
if cvalue > value:
|
||||||
|
effective_configuration[name] = cvalue
|
||||||
|
self._pending_restart = True
|
||||||
|
return effective_configuration
|
||||||
|
|
||||||
def start(self, timeout=None, block_callbacks=False, task=None):
|
def start(self, timeout=None, block_callbacks=False, task=None):
|
||||||
"""Start PostgreSQL
|
"""Start PostgreSQL
|
||||||
|
|
||||||
@@ -842,12 +890,13 @@ class Postgresql(object):
|
|||||||
self.set_state('starting')
|
self.set_state('starting')
|
||||||
self._pending_restart = False
|
self._pending_restart = False
|
||||||
|
|
||||||
self._write_postgresql_conf()
|
configuration = self._server_parameters if self.role == 'master' else self._build_effective_configuration()
|
||||||
|
self._write_postgresql_conf(configuration)
|
||||||
self.resolve_connection_addresses()
|
self.resolve_connection_addresses()
|
||||||
self._replace_pg_hba()
|
self._replace_pg_hba()
|
||||||
|
|
||||||
options = ['--{0}={1}'.format(p, self._server_parameters[p]) for p in self.CMDLINE_OPTIONS
|
options = ['--{0}={1}'.format(p, configuration[p]) for p in self.CMDLINE_OPTIONS
|
||||||
if p in self._server_parameters and p != 'wal_keep_segments']
|
if p in configuration and p != 'wal_keep_segments']
|
||||||
|
|
||||||
with self._cancellable_lock:
|
with self._cancellable_lock:
|
||||||
if self._is_cancelled:
|
if self._is_cancelled:
|
||||||
@@ -894,12 +943,12 @@ class Postgresql(object):
|
|||||||
with self._get_connection_cursor(**connect_kwargs) as cur:
|
with self._get_connection_cursor(**connect_kwargs) as cur:
|
||||||
cur.execute("SET statement_timeout = 0")
|
cur.execute("SET statement_timeout = 0")
|
||||||
if check_not_is_in_recovery:
|
if check_not_is_in_recovery:
|
||||||
cur.execute('SELECT pg_is_in_recovery()')
|
cur.execute('SELECT pg_catalog.pg_is_in_recovery()')
|
||||||
if cur.fetchone()[0]:
|
if cur.fetchone()[0]:
|
||||||
return 'is_in_recovery=true'
|
return 'is_in_recovery=true'
|
||||||
return cur.execute('CHECKPOINT')
|
return cur.execute('CHECKPOINT')
|
||||||
except psycopg2.Error:
|
except psycopg2.Error:
|
||||||
logging.exception('Exception during CHECKPOINT')
|
logger.exception('Exception during CHECKPOINT')
|
||||||
return 'not accessible or not healty'
|
return 'not accessible or not healty'
|
||||||
|
|
||||||
def stop(self, mode='fast', block_callbacks=False, checkpoint=None, on_safepoint=None):
|
def stop(self, mode='fast', block_callbacks=False, checkpoint=None, on_safepoint=None):
|
||||||
@@ -989,7 +1038,7 @@ class Postgresql(object):
|
|||||||
|
|
||||||
Should only be called when state == 'starting'
|
Should only be called when state == 'starting'
|
||||||
|
|
||||||
:returns: True iff state was changed from 'starting'
|
:returns: True if state was changed from 'starting'
|
||||||
"""
|
"""
|
||||||
ready = self.pg_isready()
|
ready = self.pg_isready()
|
||||||
|
|
||||||
@@ -1052,7 +1101,7 @@ class Postgresql(object):
|
|||||||
self.set_state('restart failed ({0})'.format(self.state))
|
self.set_state('restart failed ({0})'.format(self.state))
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def _write_postgresql_conf(self):
|
def _write_postgresql_conf(self, configuration=None):
|
||||||
# rename the original configuration if it is necessary
|
# rename the original configuration if it is necessary
|
||||||
if 'custom_conf' not in self.config and not os.path.exists(self._postgresql_base_conf):
|
if 'custom_conf' not in self.config and not os.path.exists(self._postgresql_base_conf):
|
||||||
os.rename(self._postgresql_conf, self._postgresql_base_conf)
|
os.rename(self._postgresql_conf, self._postgresql_base_conf)
|
||||||
@@ -1060,7 +1109,7 @@ class Postgresql(object):
|
|||||||
with open(self._postgresql_conf, 'w') as f:
|
with open(self._postgresql_conf, 'w') as f:
|
||||||
f.write(self._CONFIG_WARNING_HEADER)
|
f.write(self._CONFIG_WARNING_HEADER)
|
||||||
f.write("include '{0}'\n\n".format(self.config.get('custom_conf') or self._postgresql_base_conf_name))
|
f.write("include '{0}'\n\n".format(self.config.get('custom_conf') or self._postgresql_base_conf_name))
|
||||||
for name, value in sorted(self._server_parameters.items()):
|
for name, value in sorted((configuration or self._server_parameters).items()):
|
||||||
if not self._running_custom_bootstrap or name != 'hba_file':
|
if not self._running_custom_bootstrap or name != 'hba_file':
|
||||||
f.write("{0} = '{1}'\n".format(name, value))
|
f.write("{0} = '{1}'\n".format(name, value))
|
||||||
# when we are doing custom bootstrap we assume that we don't know superuser password
|
# when we are doing custom bootstrap we assume that we don't know superuser password
|
||||||
@@ -1068,9 +1117,10 @@ class Postgresql(object):
|
|||||||
# therefore we need to make sure that hba_file is not overriden
|
# therefore we need to make sure that hba_file is not overriden
|
||||||
# after changing superuser password we will "revert" all these "changes"
|
# after changing superuser password we will "revert" all these "changes"
|
||||||
if self._running_custom_bootstrap or 'hba_file' not in self._server_parameters:
|
if self._running_custom_bootstrap or 'hba_file' not in self._server_parameters:
|
||||||
f.write("hba_file = '{0}'\n".format(self._pg_hba_conf))
|
f.write("hba_file = '{0}'\n".format(self._pg_hba_conf.replace('\\', '\\\\')))
|
||||||
if 'ident_file' not in self._server_parameters:
|
if 'ident_file' not in self._server_parameters:
|
||||||
f.write("ident_file = '{0}'\n".format(os.path.join(self._config_dir, 'pg_ident.conf')))
|
s = "ident_file = '{0}'\n".format(os.path.join(self._config_dir, 'pg_ident.conf').replace('\\', '\\\\'))
|
||||||
|
f.write(s)
|
||||||
|
|
||||||
def is_healthy(self):
|
def is_healthy(self):
|
||||||
if not self.is_running():
|
if not self.is_running():
|
||||||
@@ -1079,8 +1129,10 @@ class Postgresql(object):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def write_pg_hba(self, config):
|
def write_pg_hba(self, config):
|
||||||
with open(self._pg_hba_conf, 'a') as f:
|
if not self._server_parameters.get('hba_file') and not self.config.get('pg_hba'):
|
||||||
f.write('\n{}\n'.format('\n'.join(config)))
|
with open(self._pg_hba_conf, 'a') as f:
|
||||||
|
f.write('\n{}\n'.format('\n'.join(config)))
|
||||||
|
return True
|
||||||
|
|
||||||
def _replace_pg_hba(self):
|
def _replace_pg_hba(self):
|
||||||
"""
|
"""
|
||||||
@@ -1144,7 +1196,7 @@ class Postgresql(object):
|
|||||||
('user', r.get('user')),
|
('user', r.get('user')),
|
||||||
('host', r.get('host')),
|
('host', r.get('host')),
|
||||||
('port', r.get('port')),
|
('port', r.get('port')),
|
||||||
('dbname', r.get('database')),
|
('dbname', r.get('database') or self._database),
|
||||||
('sslmode', 'prefer'),
|
('sslmode', 'prefer'),
|
||||||
('sslcompression', '1'),
|
('sslcompression', '1'),
|
||||||
]
|
]
|
||||||
@@ -1163,12 +1215,15 @@ class Postgresql(object):
|
|||||||
# Don't try to call pg_controldata during backup restore
|
# Don't try to call pg_controldata during backup restore
|
||||||
if self._version_file_exists() and self.state != 'creating replica':
|
if self._version_file_exists() and self.state != 'creating replica':
|
||||||
try:
|
try:
|
||||||
data = subprocess.check_output([self._pgcommand('pg_controldata'), self._data_dir],
|
env = {'LANG': 'C', 'LC_ALL': 'C', 'PATH': os.getenv('PATH')}
|
||||||
env={'LANG': 'C', 'LC_ALL': 'C', 'PATH': os.environ['PATH']})
|
if os.getenv('SYSTEMROOT') is not None:
|
||||||
|
env['SYSTEMROOT'] = os.getenv('SYSTEMROOT')
|
||||||
|
data = subprocess.check_output([self._pgcommand('pg_controldata'), self._data_dir], env=env)
|
||||||
if data:
|
if data:
|
||||||
data = data.decode('utf-8').splitlines()
|
data = data.decode('utf-8').splitlines()
|
||||||
# pg_controldata output depends on major verion. Some of parameters are prefixed by 'Current '
|
# pg_controldata output depends on major verion. Some of parameters are prefixed by 'Current '
|
||||||
result = {l.split(':')[0].replace('Current ', '', 1): l.split(':', 1)[1].strip() for l in data if l}
|
result = {l.split(':')[0].replace('Current ', '', 1): l.split(':', 1)[1].strip() for l in data
|
||||||
|
if l and ':' in l}
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
logger.exception("Error when calling pg_controldata")
|
logger.exception("Error when calling pg_controldata")
|
||||||
return result
|
return result
|
||||||
@@ -1186,8 +1241,9 @@ class Postgresql(object):
|
|||||||
yield cur
|
yield cur
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _get_replication_connection_cursor(self, host='localhost', port=5432, **kwargs):
|
def _get_replication_connection_cursor(self, host='localhost', port=5432, database=None, **kwargs):
|
||||||
with self._get_connection_cursor(host=host, port=int(port), database=self._database, replication=1,
|
database = database or self._database
|
||||||
|
with self._get_connection_cursor(host=host, port=int(port), database=database, replication='database',
|
||||||
user=self._replication['username'], password=self._replication['password'],
|
user=self._replication['username'], password=self._replication['password'],
|
||||||
connect_timeout=3, options='-c statement_timeout=2000') as cur:
|
connect_timeout=3, options='-c statement_timeout=2000') as cur:
|
||||||
yield cur
|
yield cur
|
||||||
@@ -1195,7 +1251,7 @@ class Postgresql(object):
|
|||||||
def check_leader_is_not_in_recovery(self, **kwargs):
|
def check_leader_is_not_in_recovery(self, **kwargs):
|
||||||
try:
|
try:
|
||||||
with self._get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **kwargs) as cur:
|
with self._get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **kwargs) as cur:
|
||||||
cur.execute('SELECT pg_is_in_recovery()')
|
cur.execute('SELECT pg_catalog.pg_is_in_recovery()')
|
||||||
if not cur.fetchone()[0]:
|
if not cur.fetchone()[0]:
|
||||||
return True
|
return True
|
||||||
logger.info('Leader is still in_recovery and therefore can\'t be used for rewind')
|
logger.info('Leader is still in_recovery and therefore can\'t be used for rewind')
|
||||||
@@ -1306,10 +1362,10 @@ class Postgresql(object):
|
|||||||
history_path = 'pg_{0}/{1:08X}.history'.format(self.wal_name, timeline)
|
history_path = 'pg_{0}/{1:08X}.history'.format(self.wal_name, timeline)
|
||||||
try:
|
try:
|
||||||
cursor = self._cursor()
|
cursor = self._cursor()
|
||||||
cursor.execute('SELECT isdir, modification FROM pg_stat_file(%s)', (history_path,))
|
cursor.execute('SELECT isdir, modification FROM pg_catalog.pg_stat_file(%s)', (history_path,))
|
||||||
isdir, modification = cursor.fetchone()
|
isdir, modification = cursor.fetchone()
|
||||||
if not isdir:
|
if not isdir:
|
||||||
cursor.execute('SELECT pg_read_file(%s)', (history_path,))
|
cursor.execute('SELECT pg_catalog.pg_read_file(%s)', (history_path,))
|
||||||
history = list(self.parse_history(cursor.fetchone()[0]))
|
history = list(self.parse_history(cursor.fetchone()[0]))
|
||||||
if history[-1][0] == timeline - 1:
|
if history[-1][0] == timeline - 1:
|
||||||
history[-1].append(modification.isoformat())
|
history[-1].append(modification.isoformat())
|
||||||
@@ -1359,7 +1415,17 @@ class Postgresql(object):
|
|||||||
def rewind_executed(self):
|
def rewind_executed(self):
|
||||||
return self._rewind_state > REWIND_STATUS.NOT_NEED
|
return self._rewind_state > REWIND_STATUS.NOT_NEED
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rewind_failed(self):
|
||||||
|
return self._rewind_state == REWIND_STATUS.FAILED
|
||||||
|
|
||||||
def follow(self, member, timeout=None):
|
def follow(self, member, timeout=None):
|
||||||
|
is_remote_master = isinstance(member, RemoteMember)
|
||||||
|
no_replication_slot = is_remote_master and member.no_replication_slot
|
||||||
|
restore_command = is_remote_master and member.restore_command
|
||||||
|
min_apply_delay = is_remote_master and member.recovery_min_apply_delay
|
||||||
|
archive_cleanup = is_remote_master and member.archive_cleanup_command
|
||||||
|
|
||||||
primary_conninfo = self.primary_conninfo(member)
|
primary_conninfo = self.primary_conninfo(member)
|
||||||
change_role = self.role in ('master', 'demoted')
|
change_role = self.role in ('master', 'demoted')
|
||||||
|
|
||||||
@@ -1367,8 +1433,16 @@ class Postgresql(object):
|
|||||||
recovery_params.update({'standby_mode': 'on', 'recovery_target_timeline': 'latest'})
|
recovery_params.update({'standby_mode': 'on', 'recovery_target_timeline': 'latest'})
|
||||||
if primary_conninfo:
|
if primary_conninfo:
|
||||||
recovery_params['primary_conninfo'] = primary_conninfo
|
recovery_params['primary_conninfo'] = primary_conninfo
|
||||||
if self.use_slots:
|
if self.use_slots and not no_replication_slot:
|
||||||
recovery_params['primary_slot_name'] = slot_name_from_member_name(self.name)
|
required_name = is_remote_master and member.data.get('primary_slot_name')
|
||||||
|
name = required_name or slot_name_from_member_name(self.name)
|
||||||
|
recovery_params['primary_slot_name'] = name
|
||||||
|
if restore_command:
|
||||||
|
recovery_params['restore_command'] = restore_command
|
||||||
|
if min_apply_delay:
|
||||||
|
recovery_params['recovery_min_apply_delay'] = min_apply_delay
|
||||||
|
if archive_cleanup:
|
||||||
|
recovery_params['archive_cleanup_command'] = archive_cleanup
|
||||||
|
|
||||||
self.write_recovery_conf(recovery_params)
|
self.write_recovery_conf(recovery_params)
|
||||||
|
|
||||||
@@ -1397,6 +1471,7 @@ class Postgresql(object):
|
|||||||
shutil.copy(config_file, backup_file)
|
shutil.copy(config_file, backup_file)
|
||||||
except IOError:
|
except IOError:
|
||||||
logger.exception('unable to create backup copies of configuration files')
|
logger.exception('unable to create backup copies of configuration files')
|
||||||
|
return True
|
||||||
|
|
||||||
def restore_configuration_files(self):
|
def restore_configuration_files(self):
|
||||||
""" restore a previously saved postgresql.conf """
|
""" restore a previously saved postgresql.conf """
|
||||||
@@ -1419,7 +1494,7 @@ class Postgresql(object):
|
|||||||
if data.get('Database cluster state') == 'in production':
|
if data.get('Database cluster state') == 'in production':
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def promote(self, wait_seconds):
|
def promote(self, wait_seconds, access_is_restricted=False):
|
||||||
if self.role == 'master':
|
if self.role == 'master':
|
||||||
return True
|
return True
|
||||||
ret = self.pg_ctl('promote', '-W')
|
ret = self.pg_ctl('promote', '-W')
|
||||||
@@ -1427,7 +1502,8 @@ class Postgresql(object):
|
|||||||
self.set_role('master')
|
self.set_role('master')
|
||||||
logger.info("cleared rewind state after becoming the leader")
|
logger.info("cleared rewind state after becoming the leader")
|
||||||
self._rewind_state = REWIND_STATUS.INITIAL
|
self._rewind_state = REWIND_STATUS.INITIAL
|
||||||
self.call_nowait(ACTION_ON_ROLE_CHANGE)
|
if not access_is_restricted:
|
||||||
|
self.call_nowait(ACTION_ON_ROLE_CHANGE)
|
||||||
ret = self._wait_promote(wait_seconds)
|
ret = self._wait_promote(wait_seconds)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@@ -1460,59 +1536,88 @@ $$""".format(name, ' '.join(options)), name, password, password)
|
|||||||
|
|
||||||
def load_replication_slots(self):
|
def load_replication_slots(self):
|
||||||
if self.use_slots and self._schedule_load_slots:
|
if self.use_slots and self._schedule_load_slots:
|
||||||
cursor = self._query("SELECT slot_name FROM pg_replication_slots WHERE slot_type='physical'")
|
replication_slots = {}
|
||||||
self._replication_slots = [r[0] for r in cursor]
|
cursor = self._query('SELECT slot_name, slot_type, plugin, database FROM pg_catalog.pg_replication_slots')
|
||||||
|
for r in cursor:
|
||||||
|
value = {'type': r[1]}
|
||||||
|
if r[1] == 'logical':
|
||||||
|
value.update({'plugin': r[2], 'database': r[3]})
|
||||||
|
replication_slots[r[0]] = value
|
||||||
|
self._replication_slots = replication_slots
|
||||||
self._schedule_load_slots = False
|
self._schedule_load_slots = False
|
||||||
|
|
||||||
def postmaster_start_time(self):
|
def postmaster_start_time(self):
|
||||||
try:
|
try:
|
||||||
cursor = self.query("""SELECT to_char(pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ')""")
|
cursor = self.query("SELECT pg_catalog.to_char(pg_catalog.pg_postmaster_start_time(),"
|
||||||
|
" 'YYYY-MM-DD HH24:MI:SS.MS TZ')")
|
||||||
return cursor.fetchone()[0]
|
return cursor.fetchone()[0]
|
||||||
except psycopg2.Error:
|
except psycopg2.Error:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def drop_replication_slot(self, name):
|
||||||
|
cursor = self._query(('SELECT pg_catalog.pg_drop_replication_slot(%s) WHERE EXISTS (SELECT 1 ' +
|
||||||
|
'FROM pg_catalog.pg_replication_slots WHERE slot_name = %s AND NOT active)'), name, name)
|
||||||
|
# In normal situation rowcount should be 1, otherwise either slot doesn't exists or it is still active
|
||||||
|
return cursor.rowcount == 1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compare_slots(s1, s2):
|
||||||
|
return s1['type'] == s2['type'] and\
|
||||||
|
(s1['type'] == 'physical' or s1['database'] == s2['database'] and s1['plugin'] == s2['plugin'])
|
||||||
|
|
||||||
def sync_replication_slots(self, cluster):
|
def sync_replication_slots(self, cluster):
|
||||||
if self.use_slots:
|
if self.use_slots:
|
||||||
try:
|
try:
|
||||||
self.load_replication_slots()
|
self.load_replication_slots()
|
||||||
# if the replicatefrom tag is set on the member - we should not create the replication slot for it on
|
|
||||||
# the current master, because that member would replicate from elsewhere. We still create the slot if
|
|
||||||
# the replicatefrom destination member is currently not a member of the cluster (fallback to the
|
|
||||||
# master), or if replicatefrom destination member happens to be the current master
|
|
||||||
if self.role == 'master':
|
|
||||||
slot_members = [m.name for m in cluster.members if m.name != self.name and
|
|
||||||
(m.replicatefrom is None or m.replicatefrom == self.name or
|
|
||||||
not cluster.has_member(m.replicatefrom))]
|
|
||||||
else:
|
|
||||||
# only manage slots for replicas that replicate from this one, except for the leader among them
|
|
||||||
slot_members = [m.name for m in cluster.members if m.replicatefrom == self.name and
|
|
||||||
m.name != cluster.leader.name]
|
|
||||||
slots = set(slot_name_from_member_name(name) for name in slot_members)
|
|
||||||
|
|
||||||
if len(slots) < len(slot_members):
|
slots = cluster.get_replication_slots(self.name, self.role)
|
||||||
# Find which names are conflicting for a nicer error message
|
|
||||||
slot_conflicts = defaultdict(list)
|
|
||||||
for name in slot_members:
|
|
||||||
slot_conflicts[slot_name_from_member_name(name)].append(name)
|
|
||||||
logger.error("Following cluster members share a replication slot name: %s",
|
|
||||||
"; ".join("{} map to {}".format(", ".join(v), k)
|
|
||||||
for k, v in slot_conflicts.items() if len(v) > 1))
|
|
||||||
|
|
||||||
# drop unused slots
|
# drop old replication slots which are not presented in desired slots
|
||||||
for slot in set(self._replication_slots) - slots:
|
for name in set(self._replication_slots) - set(slots):
|
||||||
cursor = self._query("""SELECT pg_drop_replication_slot(%s)
|
if not self.drop_replication_slot(name):
|
||||||
WHERE EXISTS(SELECT 1 FROM pg_replication_slots
|
logger.error("Failed to drop replication slot '%s'", name)
|
||||||
WHERE slot_name = %s AND NOT active)""", slot, slot)
|
self._schedule_load_slots = True
|
||||||
|
|
||||||
if cursor.rowcount != 1: # Either slot doesn't exists or it is still active
|
immediately_reserve = ', true' if self._major_version >= 90600 else ''
|
||||||
self._schedule_load_slots = True # schedule load_replication_slots on the next iteration
|
|
||||||
|
|
||||||
# create new slots
|
logical_slots = defaultdict(dict)
|
||||||
for slot in slots - set(self._replication_slots):
|
for name, value in slots.items():
|
||||||
self._query("""SELECT pg_create_physical_replication_slot(%s)
|
if name in self._replication_slots and not self.compare_slots(value, self._replication_slots[name]):
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM pg_replication_slots
|
logger.info("Trying to drop replication slot '%s' because value is changing from %s to %s",
|
||||||
WHERE slot_name = %s)""", slot, slot)
|
name, self._replication_slots[name], value)
|
||||||
|
if not self.drop_replication_slot(name):
|
||||||
|
logger.error("Failed to drop replication slot '%s'", name)
|
||||||
|
self._schedule_load_slots = True
|
||||||
|
continue
|
||||||
|
self._replication_slots.pop(name)
|
||||||
|
if name not in self._replication_slots:
|
||||||
|
if value['type'] == 'physical':
|
||||||
|
try:
|
||||||
|
self._query(("SELECT pg_catalog.pg_create_physical_replication_slot(%s{0})" +
|
||||||
|
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
|
||||||
|
" WHERE slot_type = 'physical' AND slot_name = %s)").format(
|
||||||
|
immediately_reserve), name, name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to create physical replication slot '%s'", name)
|
||||||
|
self._schedule_load_slots = True
|
||||||
|
elif value['type'] == 'logical' and name not in self._replication_slots:
|
||||||
|
logical_slots[value['database']][name] = value
|
||||||
|
|
||||||
|
# create new logical slots
|
||||||
|
for database, values in logical_slots.items():
|
||||||
|
conn_kwargs = self._local_connect_kwargs
|
||||||
|
conn_kwargs['database'] = database
|
||||||
|
with self._get_connection_cursor(**conn_kwargs) as cur:
|
||||||
|
for name, value in values.items():
|
||||||
|
try:
|
||||||
|
cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)" +
|
||||||
|
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
|
||||||
|
" WHERE slot_type = 'logical' AND slot_name = %s)",
|
||||||
|
(name, value['plugin'], name))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to create logical replication slot '%s' plugin='%s'",
|
||||||
|
name, value['plugin'])
|
||||||
|
self._schedule_load_slots = True
|
||||||
self._replication_slots = slots
|
self._replication_slots = slots
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('Exception when changing replication slots')
|
logger.exception('Exception when changing replication slots')
|
||||||
@@ -1546,6 +1651,7 @@ $$""".format(name, ' '.join(options)), name, password, password)
|
|||||||
|
|
||||||
def bootstrap(self, config):
|
def bootstrap(self, config):
|
||||||
""" Initialize a new node from scratch and start it. """
|
""" Initialize a new node from scratch and start it. """
|
||||||
|
pg_hba = config.get('pg_hba', [])
|
||||||
method = config.get('method') or 'initdb'
|
method = config.get('method') or 'initdb'
|
||||||
self._running_custom_bootstrap = method != 'initdb' and method in config and 'command' in config[method]
|
self._running_custom_bootstrap = method != 'initdb' and method in config and 'command' in config[method]
|
||||||
if self._running_custom_bootstrap:
|
if self._running_custom_bootstrap:
|
||||||
@@ -1553,7 +1659,8 @@ $$""".format(name, ' '.join(options)), name, password, password)
|
|||||||
config = config[method]
|
config = config[method]
|
||||||
else:
|
else:
|
||||||
do_initialize = self._initdb
|
do_initialize = self._initdb
|
||||||
return do_initialize(config) and self._configure_server_parameters() and self.start()
|
return do_initialize(config) and self.write_pg_hba(pg_hba) and self.save_configuration_files() \
|
||||||
|
and self._configure_server_parameters() and self.start()
|
||||||
|
|
||||||
def post_bootstrap(self, config, task):
|
def post_bootstrap(self, config, task):
|
||||||
try:
|
try:
|
||||||
@@ -1581,9 +1688,12 @@ $$""".format(name, ' '.join(options)), name, password, password)
|
|||||||
self.restart()
|
self.restart()
|
||||||
else:
|
else:
|
||||||
self._replace_pg_hba()
|
self._replace_pg_hba()
|
||||||
self.reload()
|
if self.pending_restart:
|
||||||
time.sleep(1) # give a time to postgres to "reload" configuration files
|
self.restart()
|
||||||
self.close_connection() # close connection to reconnect with a new password
|
else:
|
||||||
|
self.reload()
|
||||||
|
time.sleep(1) # give a time to postgres to "reload" configuration files
|
||||||
|
self.close_connection() # close connection to reconnect with a new password
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('post_bootstrap')
|
logger.exception('post_bootstrap')
|
||||||
task.complete(False)
|
task.complete(False)
|
||||||
@@ -1614,23 +1724,27 @@ $$""".format(name, ' '.join(options)), name, password, password)
|
|||||||
logger.exception('Could not remove data directory %s', self._data_dir)
|
logger.exception('Could not remove data directory %s', self._data_dir)
|
||||||
self.move_data_directory()
|
self.move_data_directory()
|
||||||
|
|
||||||
def basebackup(self, conn_url, env):
|
def basebackup(self, conn_url, env, options):
|
||||||
# creates a replica data dir using pg_basebackup.
|
# creates a replica data dir using pg_basebackup.
|
||||||
# this is the default, built-in create_replica_method
|
# this is the default, built-in create_replica_methods
|
||||||
# tries twice, then returns failure (as 1)
|
# tries twice, then returns failure (as 1)
|
||||||
# uses "stream" as the xlog-method to avoid sync issues
|
# uses "stream" as the xlog-method to avoid sync issues
|
||||||
|
# supports additional user-supplied options, those are not validated
|
||||||
maxfailures = 2
|
maxfailures = 2
|
||||||
ret = 1
|
ret = 1
|
||||||
|
not_allowed_options = ('pgdata', 'format', 'wal-method', 'xlog-method', 'gzip',
|
||||||
|
'version', 'compress', 'dbname', 'host', 'port', 'username', 'password')
|
||||||
|
user_options = self.process_user_options('basebackup', options, not_allowed_options, logger.error)
|
||||||
|
|
||||||
for bbfailures in range(0, maxfailures):
|
for bbfailures in range(0, maxfailures):
|
||||||
with self._cancellable_lock:
|
with self._cancellable_lock:
|
||||||
if self._is_cancelled:
|
if self._is_cancelled:
|
||||||
break
|
break
|
||||||
if not self.data_directory_empty():
|
if not self.data_directory_empty():
|
||||||
self.remove_data_directory()
|
self.remove_data_directory()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ret = self.cancellable_subprocess_call([self._pgcommand('pg_basebackup'), '--pgdata=' + self._data_dir,
|
ret = self.cancellable_subprocess_call([self._pgcommand('pg_basebackup'), '--pgdata=' + self._data_dir,
|
||||||
'-X', 'stream', '--dbname=' + conn_url], env=env)
|
'-X', 'stream', '--dbname=' + conn_url] + user_options, env=env)
|
||||||
if ret == 0:
|
if ret == 0:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -1660,9 +1774,9 @@ $$""".format(name, ' '.join(options)), name, password, password)
|
|||||||
# Pick candidates based on who has flushed WAL farthest.
|
# Pick candidates based on who has flushed WAL farthest.
|
||||||
# TODO: for synchronous_commit = remote_write we actually want to order on write_location
|
# TODO: for synchronous_commit = remote_write we actually want to order on write_location
|
||||||
for app_name, state, sync_state in self.query(
|
for app_name, state, sync_state in self.query(
|
||||||
"""SELECT LOWER(application_name), state, sync_state
|
"SELECT pg_catalog.lower(application_name), state, sync_state"
|
||||||
FROM pg_stat_replication
|
" FROM pg_catalog.pg_stat_replication"
|
||||||
ORDER BY flush_{0} DESC""".format(self.lsn_name)):
|
" ORDER BY flush_{0} DESC".format(self.lsn_name)):
|
||||||
member = members.get(app_name)
|
member = members.get(app_name)
|
||||||
if state != 'streaming' or not member or member.tags.get('nosync', False):
|
if state != 'streaming' or not member or member.tags.get('nosync', False):
|
||||||
continue
|
continue
|
||||||
@@ -1671,7 +1785,7 @@ $$""".format(name, ' '.join(options)), name, password, password)
|
|||||||
if sync_state == 'potential' and app_name == current:
|
if sync_state == 'potential' and app_name == current:
|
||||||
# Prefer current even if not the best one any more to avoid indecisivness and spurious swaps.
|
# Prefer current even if not the best one any more to avoid indecisivness and spurious swaps.
|
||||||
return current, False
|
return current, False
|
||||||
if sync_state == 'async':
|
if sync_state in ('async', 'potential'):
|
||||||
candidates.append(app_name)
|
candidates.append(app_name)
|
||||||
|
|
||||||
if candidates:
|
if candidates:
|
||||||
@@ -1790,10 +1904,20 @@ $$""".format(name, ' '.join(options)), name, password, password)
|
|||||||
return self.single_user_mode(options=opts) == 0 or None
|
return self.single_user_mode(options=opts) == 0 or None
|
||||||
|
|
||||||
def cancellable_subprocess_call(self, *args, **kwargs):
|
def cancellable_subprocess_call(self, *args, **kwargs):
|
||||||
communicate_input = kwargs.pop('communicate_input', None)
|
|
||||||
for s in ('stdin', 'stdout', 'stderr'):
|
for s in ('stdin', 'stdout', 'stderr'):
|
||||||
kwargs.pop(s, None)
|
kwargs.pop(s, None)
|
||||||
|
|
||||||
|
communicate_input = 'communicate_input' in kwargs
|
||||||
|
if communicate_input:
|
||||||
|
input_data = kwargs.pop('communicate_input', None)
|
||||||
|
if not isinstance(input_data, string_types):
|
||||||
|
input_data = ''
|
||||||
|
if input_data and input_data[-1] != '\n':
|
||||||
|
input_data += '\n'
|
||||||
|
kwargs['stdin'] = subprocess.PIPE
|
||||||
|
kwargs['stdout'] = open(os.devnull, 'w')
|
||||||
|
kwargs['stderr'] = subprocess.STDOUT
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self._cancellable_lock:
|
with self._cancellable_lock:
|
||||||
if self._is_cancelled:
|
if self._is_cancelled:
|
||||||
@@ -1803,10 +1927,8 @@ $$""".format(name, ' '.join(options)), name, password, password)
|
|||||||
self._cancellable = subprocess.Popen(*args, **kwargs)
|
self._cancellable = subprocess.Popen(*args, **kwargs)
|
||||||
|
|
||||||
if communicate_input:
|
if communicate_input:
|
||||||
kwargs['stdin'] = subprocess.PIPE
|
if input_data:
|
||||||
if communicate_input[-1] != '\n':
|
self._cancellable.communicate(input_data)
|
||||||
communicate_input += '\n'
|
|
||||||
self._cancellable.communicate(communicate_input + '\n')
|
|
||||||
self._cancellable.stdin.close()
|
self._cancellable.stdin.close()
|
||||||
|
|
||||||
return self._cancellable.wait()
|
return self._cancellable.wait()
|
||||||
@@ -1833,3 +1955,12 @@ $$""".format(name, ' '.join(options)), name, password, password)
|
|||||||
with self._cancellable_lock:
|
with self._cancellable_lock:
|
||||||
if self._cancellable is not None and self._cancellable.returncode is None:
|
if self._cancellable is not None and self._cancellable.returncode is None:
|
||||||
self._cancellable.kill()
|
self._cancellable.kill()
|
||||||
|
|
||||||
|
def schedule_sanity_checks_after_pause(self):
|
||||||
|
"""
|
||||||
|
After coming out of pause we have to:
|
||||||
|
1. sync replication slots, because it might happen that slots were removed
|
||||||
|
2. get new 'Database system identifier' to make sure that it wasn't changed
|
||||||
|
"""
|
||||||
|
self._schedule_load_slots = self.use_slots
|
||||||
|
self._sysid = None
|
||||||
|
|||||||
+83
-30
@@ -12,11 +12,12 @@ logger = logging.getLogger(__name__)
|
|||||||
STOP_SIGNALS = {
|
STOP_SIGNALS = {
|
||||||
'smart': signal.SIGTERM,
|
'smart': signal.SIGTERM,
|
||||||
'fast': signal.SIGINT,
|
'fast': signal.SIGINT,
|
||||||
'immediate': signal.SIGQUIT,
|
'immediate': signal.SIGQUIT if os.name != 'nt' else signal.SIGABRT,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class PostmasterProcess(psutil.Process):
|
class PostmasterProcess(psutil.Process):
|
||||||
|
|
||||||
def __init__(self, pid):
|
def __init__(self, pid):
|
||||||
self.is_single_user = False
|
self.is_single_user = False
|
||||||
if pid < 0:
|
if pid < 0:
|
||||||
@@ -24,32 +25,56 @@ class PostmasterProcess(psutil.Process):
|
|||||||
self.is_single_user = True
|
self.is_single_user = True
|
||||||
super(PostmasterProcess, self).__init__(pid)
|
super(PostmasterProcess, self).__init__(pid)
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
def from_pidfile(cls, pidfile):
|
def _read_postmaster_pidfile(data_dir):
|
||||||
try:
|
"""Reads and parses postmaster.pid from the data directory
|
||||||
pid = int(pidfile.get('pid', 0))
|
|
||||||
if not pid:
|
|
||||||
return None
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
:returns dictionary of values if successful, empty dictionary otherwise
|
||||||
|
"""
|
||||||
|
pid_line_names = ['pid', 'data_dir', 'start_time', 'port', 'socket_dir', 'listen_addr', 'shmem_key']
|
||||||
try:
|
try:
|
||||||
proc = cls(pid)
|
with open(os.path.join(data_dir, 'postmaster.pid')) as f:
|
||||||
except psutil.NoSuchProcess:
|
return {name: line.rstrip('\n') for name, line in zip(pid_line_names, f)}
|
||||||
return None
|
except IOError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _is_postmaster_process(self):
|
||||||
try:
|
try:
|
||||||
start_time = int(pidfile.get('start_time', 0))
|
start_time = int(self._postmaster_pid.get('start_time', 0))
|
||||||
if start_time and abs(proc.create_time() - start_time) > 3:
|
if start_time and abs(self.create_time() - start_time) > 3:
|
||||||
return None
|
logger.info('Process %s is not postmaster, too much difference between PID file start time %s and '
|
||||||
|
'process start time %s', self.pid, self.create_time(), start_time)
|
||||||
|
return False
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning("Garbage start time value in pid file: %r", pidfile.get('start_time'))
|
logger.warning('Garbage start time value in pid file: %r', self._postmaster_pid.get('start_time'))
|
||||||
|
|
||||||
# Extra safety check. The process can't be ourselves, our parent or our direct child.
|
# Extra safety check. The process can't be ourselves, our parent or our direct child.
|
||||||
if proc.pid == os.getpid() or proc.pid == os.getppid() or proc.parent() == os.getpid():
|
if self.pid == os.getpid() or self.pid == os.getppid() or self.ppid() == os.getpid():
|
||||||
return None
|
logger.info('Patroni (pid=%s, ppid=%s), "fake postmaster" (pid=%s, ppid=%s)',
|
||||||
|
os.getpid(), os.getppid(), self.pid, self.ppid())
|
||||||
|
return False
|
||||||
|
|
||||||
return proc
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_pidfile(cls, data_dir):
|
||||||
|
postmaster_pid = PostmasterProcess._read_postmaster_pidfile(data_dir)
|
||||||
|
try:
|
||||||
|
pid = int(postmaster_pid.get('pid', 0))
|
||||||
|
if pid:
|
||||||
|
proc = cls(pid)
|
||||||
|
proc._postmaster_pid = postmaster_pid
|
||||||
|
return proc
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_pidfile(data_dir):
|
||||||
|
try:
|
||||||
|
proc = PostmasterProcess._from_pidfile(data_dir)
|
||||||
|
return proc if proc and proc._is_postmaster_process() else None
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_pid(cls, pid):
|
def from_pid(cls, pid):
|
||||||
@@ -82,14 +107,26 @@ class PostmasterProcess(psutil.Process):
|
|||||||
"autovacuum launcher|autovacuum worker|stats collector|wal receiver|archiver|"
|
"autovacuum launcher|autovacuum worker|stats collector|wal receiver|archiver|"
|
||||||
"wal sender) process|bgworker: )")
|
"wal sender) process|bgworker: )")
|
||||||
|
|
||||||
user_backends = [p for p in self.children() if not aux_proc_re.match(p.cmdline()[0])]
|
try:
|
||||||
logger.debug("Waiting for user backends {0} to close".format(
|
user_backends = []
|
||||||
",".join(p.cmdline()[0] for p in user_backends)))
|
user_backends_cmdlines = []
|
||||||
psutil.wait_procs(user_backends)
|
for child in self.children():
|
||||||
logger.debug("Backends closed")
|
try:
|
||||||
|
cmdline = child.cmdline()[0]
|
||||||
|
if not aux_proc_re.match(cmdline):
|
||||||
|
user_backends.append(child)
|
||||||
|
user_backends_cmdlines.append(cmdline)
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
pass
|
||||||
|
if user_backends:
|
||||||
|
logger.debug('Waiting for user backends %s to close', ', '.join(user_backends_cmdlines))
|
||||||
|
psutil.wait_procs(user_backends)
|
||||||
|
logger.debug("Backends closed")
|
||||||
|
except psutil.Error:
|
||||||
|
logger.exception('wait_for_user_backends_to_close')
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
def start(cls, pgcommand, data_dir, conf, options):
|
def start(pgcommand, data_dir, conf, options):
|
||||||
# Unfortunately `pg_ctl start` does not return postmaster pid to us. Without this information
|
# Unfortunately `pg_ctl start` does not return postmaster pid to us. Without this information
|
||||||
# it is hard to know the current state of postgres startup, so we had to reimplement pg_ctl start
|
# it is hard to know the current state of postgres startup, so we had to reimplement pg_ctl start
|
||||||
# in python. It will start postgres, wait for port to be open and wait until postgres will start
|
# in python. It will start postgres, wait for port to be open and wait until postgres will start
|
||||||
@@ -101,10 +138,26 @@ class PostmasterProcess(psutil.Process):
|
|||||||
# of init process to take care about postmaster.
|
# of init process to take care about postmaster.
|
||||||
# In order to make everything portable we can't use fork&exec approach here, so we will call
|
# In order to make everything portable we can't use fork&exec approach here, so we will call
|
||||||
# ourselves and pass list of arguments which must be used to start postgres.
|
# ourselves and pass list of arguments which must be used to start postgres.
|
||||||
proc = call_self(['pg_ctl_start', pgcommand, '-D', data_dir,
|
# On Windows, in order to run a side-by-side assembly the specified env must include a valid SYSTEMROOT.
|
||||||
'--config-file={}'.format(conf)] + options, close_fds=True,
|
env = {p: os.environ[p] for p in ('PATH', 'LD_LIBRARY_PATH', 'LC_ALL', 'LANG', 'SYSTEMROOT') if p in os.environ}
|
||||||
preexec_fn=os.setsid, stdout=subprocess.PIPE,
|
try:
|
||||||
env={p: os.environ[p] for p in ('PATH', 'LC_ALL', 'LANG') if p in os.environ})
|
proc = PostmasterProcess._from_pidfile(data_dir)
|
||||||
|
if proc and not proc._is_postmaster_process():
|
||||||
|
# Upon start postmaster process performs various safety checks if there is a postmaster.pid
|
||||||
|
# file in the data directory. Although Patroni already detected that the running process
|
||||||
|
# corresponding to the postmaster.pid is not a postmaster, the new postmaster might fail
|
||||||
|
# to start, because it thinks that postmaster.pid is already locked.
|
||||||
|
# Important!!! Unlink of postmaster.pid isn't an option, because it has a lot of nasty race conditions.
|
||||||
|
# Luckily there is a workaround to this problem, we can pass the pid from postmaster.pid
|
||||||
|
# in the `PG_GRANDPARENT_PID` environment variable and postmaster will ignore it.
|
||||||
|
logger.info("Telling pg_ctl that it is safe to ignore postmaster.pid for process %s", proc.pid)
|
||||||
|
env['PG_GRANDPARENT_PID'] = str(proc.pid)
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
pass
|
||||||
|
cmdline = [pgcommand, '-D', data_dir, '--config-file={}'.format(conf)] + options
|
||||||
|
logger.debug("Starting postgres: %s", " ".join(cmdline))
|
||||||
|
proc = call_self(['pg_ctl_start'] + cmdline, close_fds=(os.name != 'nt'),
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env)
|
||||||
pid = int(proc.stdout.readline().strip())
|
pid = int(proc.stdout.readline().strip())
|
||||||
proc.wait()
|
proc.wait()
|
||||||
logger.info('postmaster pid=%s', pid)
|
logger.info('postmaster pid=%s', pid)
|
||||||
|
|||||||
@@ -224,13 +224,12 @@ class WALERestore(object):
|
|||||||
lsn_name = 'location'
|
lsn_name = 'location'
|
||||||
con.autocommit = True
|
con.autocommit = True
|
||||||
with con.cursor() as cur:
|
with con.cursor() as cur:
|
||||||
cur.execute("""SELECT CASE WHEN pg_is_in_recovery()
|
cur.execute(("SELECT CASE WHEN pg_catalog.pg_is_in_recovery()"
|
||||||
THEN GREATEST(
|
" THEN GREATEST(pg_catalog.pg_{0}_{1}_diff(COALESCE("
|
||||||
pg_{0}_{1}_diff(COALESCE(
|
"pg_last_{0}_receive_{1}(), '0/0'), %s)::bigint, "
|
||||||
pg_last_{0}_receive_{1}(), '0/0'), %s)::bigint,
|
"pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), %s)::bigint)"
|
||||||
pg_{0}_{1}_diff(pg_last_{0}_replay_{1}(), %s)::bigint)
|
" ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), %s)::bigint"
|
||||||
ELSE pg_{0}_{1}_diff(pg_current_{0}_{1}(), %s)::bigint
|
" END").format(wal_name, lsn_name),
|
||||||
END""".format(wal_name, lsn_name),
|
|
||||||
(backup_start_lsn, backup_start_lsn, backup_start_lsn))
|
(backup_start_lsn, backup_start_lsn, backup_start_lsn))
|
||||||
|
|
||||||
diff_in_bytes = int(cur.fetchone()[0])
|
diff_in_bytes = int(cur.fetchone()[0])
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
__version__ = '1.4'
|
__version__ = '1.5.2'
|
||||||
|
|||||||
+11
-2
@@ -11,6 +11,11 @@ restapi:
|
|||||||
# username: username
|
# username: username
|
||||||
# password: password
|
# password: password
|
||||||
|
|
||||||
|
# ctl:
|
||||||
|
# insecure: false # Allow connections to SSL sites without certs
|
||||||
|
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||||
|
# cacert: /etc/ssl/certs/ssl-cacert-snakeoil.pem
|
||||||
|
|
||||||
etcd:
|
etcd:
|
||||||
host: 127.0.0.1:2379
|
host: 127.0.0.1:2379
|
||||||
|
|
||||||
@@ -24,6 +29,10 @@ bootstrap:
|
|||||||
maximum_lag_on_failover: 1048576
|
maximum_lag_on_failover: 1048576
|
||||||
# master_start_timeout: 300
|
# master_start_timeout: 300
|
||||||
# synchronous_mode: false
|
# synchronous_mode: false
|
||||||
|
#standby_cluster:
|
||||||
|
#host: 127.0.0.1
|
||||||
|
#port: 1111
|
||||||
|
#primary_slot_name: patroni
|
||||||
postgresql:
|
postgresql:
|
||||||
use_pg_rewind: true
|
use_pg_rewind: true
|
||||||
# use_slots: true
|
# use_slots: true
|
||||||
@@ -31,8 +40,8 @@ bootstrap:
|
|||||||
# wal_level: hot_standby
|
# wal_level: hot_standby
|
||||||
# hot_standby: "on"
|
# hot_standby: "on"
|
||||||
# wal_keep_segments: 8
|
# wal_keep_segments: 8
|
||||||
# max_wal_senders: 5
|
# max_wal_senders: 10
|
||||||
# max_replication_slots: 5
|
# max_replication_slots: 10
|
||||||
# wal_log_hints: "on"
|
# wal_log_hints: "on"
|
||||||
# archive_mode: "on"
|
# archive_mode: "on"
|
||||||
# archive_timeout: 1800s
|
# archive_timeout: 1800s
|
||||||
|
|||||||
+10
-2
@@ -11,6 +11,11 @@ restapi:
|
|||||||
# username: username
|
# username: username
|
||||||
# password: password
|
# password: password
|
||||||
|
|
||||||
|
# ctl:
|
||||||
|
# insecure: false # Allow connections to SSL sites without certs
|
||||||
|
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||||
|
# cacert: /etc/ssl/certs/ssl-cacert-snakeoil.pem
|
||||||
|
|
||||||
etcd:
|
etcd:
|
||||||
host: 127.0.0.1:2379
|
host: 127.0.0.1:2379
|
||||||
|
|
||||||
@@ -29,8 +34,8 @@ bootstrap:
|
|||||||
# wal_level: hot_standby
|
# wal_level: hot_standby
|
||||||
# hot_standby: "on"
|
# hot_standby: "on"
|
||||||
# wal_keep_segments: 8
|
# wal_keep_segments: 8
|
||||||
# max_wal_senders: 5
|
# max_wal_senders: 10
|
||||||
# max_replication_slots: 5
|
# max_replication_slots: 10
|
||||||
# wal_log_hints: "on"
|
# wal_log_hints: "on"
|
||||||
# archive_mode: "on"
|
# archive_mode: "on"
|
||||||
# archive_timeout: 1800s
|
# archive_timeout: 1800s
|
||||||
@@ -75,6 +80,9 @@ postgresql:
|
|||||||
password: zalando
|
password: zalando
|
||||||
parameters:
|
parameters:
|
||||||
unix_socket_directories: '.'
|
unix_socket_directories: '.'
|
||||||
|
basebackup:
|
||||||
|
- verbose
|
||||||
|
- max-rate: 100M
|
||||||
tags:
|
tags:
|
||||||
nofailover: false
|
nofailover: false
|
||||||
noloadbalance: false
|
noloadbalance: false
|
||||||
|
|||||||
+7
-2
@@ -11,6 +11,11 @@ restapi:
|
|||||||
username: username
|
username: username
|
||||||
password: password
|
password: password
|
||||||
|
|
||||||
|
# ctl:
|
||||||
|
# insecure: false # Allow connections to SSL sites without certs
|
||||||
|
# certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||||
|
# cacert: /etc/ssl/certs/ssl-cacert-snakeoil.pem
|
||||||
|
|
||||||
etcd:
|
etcd:
|
||||||
host: 127.0.0.1:2379
|
host: 127.0.0.1:2379
|
||||||
|
|
||||||
@@ -29,8 +34,8 @@ bootstrap:
|
|||||||
# wal_level: hot_standby
|
# wal_level: hot_standby
|
||||||
# hot_standby: "on"
|
# hot_standby: "on"
|
||||||
# wal_keep_segments: 8
|
# wal_keep_segments: 8
|
||||||
# max_wal_senders: 5
|
# max_wal_senders: 10
|
||||||
# max_replication_slots: 5
|
# max_replication_slots: 10
|
||||||
# wal_log_hints: "on"
|
# wal_log_hints: "on"
|
||||||
# archive_mode: "on"
|
# archive_mode: "on"
|
||||||
# archive_timeout: 1800s
|
# archive_timeout: 1800s
|
||||||
|
|||||||
+1
-1
@@ -13,4 +13,4 @@ tzlocal
|
|||||||
python-dateutil
|
python-dateutil
|
||||||
psutil
|
psutil
|
||||||
cdiff
|
cdiff
|
||||||
kubernetes==3.0.0
|
kubernetes>=2.0.0,<=7.0.0,!=4.0.*,!=5.0.*
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ VERSION = read_version(MAIN_PACKAGE)
|
|||||||
DESCRIPTION = 'PostgreSQL High-Available orchestrator and CLI'
|
DESCRIPTION = 'PostgreSQL High-Available orchestrator and CLI'
|
||||||
LICENSE = 'The MIT License'
|
LICENSE = 'The MIT License'
|
||||||
URL = 'https://github.com/zalando/patroni'
|
URL = 'https://github.com/zalando/patroni'
|
||||||
AUTHOR = 'Alexander Kukushkin, Oleksii Kliukin, Feike Steenbergen'
|
AUTHOR = 'Alexander Kukushkin, Dmitrii Dolgov, Oleksii Kliukin'
|
||||||
AUTHOR_EMAIL = '[email protected], [email protected], [email protected]'
|
AUTHOR_EMAIL = '[email protected], [email protected], [email protected]'
|
||||||
KEYWORDS = 'etcd governor patroni postgresql postgres ha haproxy confd zookeeper exhibitor consul streaming replication'
|
KEYWORDS = 'etcd governor patroni postgresql postgres ha haproxy confd' +\
|
||||||
|
' zookeeper exhibitor consul streaming replication kubernetes k8s'
|
||||||
|
|
||||||
COVERAGE_XML = True
|
COVERAGE_XML = True
|
||||||
COVERAGE_HTML = False
|
COVERAGE_HTML = False
|
||||||
@@ -43,16 +44,22 @@ JUNIT_XML = True
|
|||||||
# Add here all kinds of additional classifiers as defined under
|
# Add here all kinds of additional classifiers as defined under
|
||||||
# https://pypi.python.org/pypi?%3Aaction=list_classifiers
|
# https://pypi.python.org/pypi?%3Aaction=list_classifiers
|
||||||
CLASSIFIERS = [
|
CLASSIFIERS = [
|
||||||
'Development Status :: 4 - Beta',
|
'Development Status :: 5 - Production/Stable',
|
||||||
'Environment :: Console',
|
'Environment :: Console',
|
||||||
'Intended Audience :: Developers',
|
'Intended Audience :: Developers',
|
||||||
'Intended Audience :: System Administrators',
|
'Intended Audience :: System Administrators',
|
||||||
'License :: OSI Approved :: MIT License',
|
'License :: OSI Approved :: MIT License',
|
||||||
|
'Operating System :: MacOS',
|
||||||
'Operating System :: POSIX :: Linux',
|
'Operating System :: POSIX :: Linux',
|
||||||
|
'Operating System :: POSIX :: BSD :: FreeBSD',
|
||||||
|
'Operating System :: Microsoft :: Windows',
|
||||||
'Programming Language :: Python',
|
'Programming Language :: Python',
|
||||||
'Programming Language :: Python :: 2.7',
|
'Programming Language :: Python :: 2.7',
|
||||||
|
'Programming Language :: Python :: 3',
|
||||||
'Programming Language :: Python :: 3.4',
|
'Programming Language :: Python :: 3.4',
|
||||||
'Programming Language :: Python :: 3.5',
|
'Programming Language :: Python :: 3.5',
|
||||||
|
'Programming Language :: Python :: 3.6',
|
||||||
|
'Programming Language :: Python :: 3.7',
|
||||||
'Programming Language :: Python :: Implementation :: CPython',
|
'Programming Language :: Python :: Implementation :: CPython',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -100,6 +107,8 @@ class PyTest(TestCommand):
|
|||||||
silence = logging.WARNING
|
silence = logging.WARNING
|
||||||
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=os.getenv('LOGLEVEL', silence))
|
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=os.getenv('LOGLEVEL', silence))
|
||||||
params['args'] += ['-s' if logging.getLogger().getEffectiveLevel() < silence else '--capture=fd']
|
params['args'] += ['-s' if logging.getLogger().getEffectiveLevel() < silence else '--capture=fd']
|
||||||
|
if not os.getenv('SYSTEMROOT'):
|
||||||
|
os.environ['SYSTEMROOT'] = '/'
|
||||||
errno = pytest.main(**params)
|
errno = pytest.main(**params)
|
||||||
sys.exit(errno)
|
sys.exit(errno)
|
||||||
|
|
||||||
|
|||||||
+27
-5
@@ -51,6 +51,10 @@ class MockHa(object):
|
|||||||
state_handler = MockPostgresql()
|
state_handler = MockPostgresql()
|
||||||
watchdog = MockWatchdog()
|
watchdog = MockWatchdog()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_leader():
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def reinitialize(_):
|
def reinitialize(_):
|
||||||
return 'reinitialize'
|
return 'reinitialize'
|
||||||
@@ -91,6 +95,10 @@ class MockHa(object):
|
|||||||
def is_paused():
|
def is_paused():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_standby_cluster():
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class MockPatroni(object):
|
class MockPatroni(object):
|
||||||
|
|
||||||
@@ -123,16 +131,14 @@ class MockRequest(object):
|
|||||||
|
|
||||||
class MockRestApiServer(RestApiServer):
|
class MockRestApiServer(RestApiServer):
|
||||||
|
|
||||||
def __init__(self, Handler, request):
|
def __init__(self, Handler, request, config=None):
|
||||||
self.socket = 0
|
self.socket = 0
|
||||||
self.serve_forever = Mock()
|
self.serve_forever = Mock()
|
||||||
BaseHTTPServer.HTTPServer.__init__ = Mock()
|
BaseHTTPServer.HTTPServer.__init__ = Mock()
|
||||||
MockRestApiServer._BaseServer__is_shut_down = Mock()
|
MockRestApiServer._BaseServer__is_shut_down = Mock()
|
||||||
MockRestApiServer._BaseServer__shutdown_request = True
|
MockRestApiServer._BaseServer__shutdown_request = True
|
||||||
config = {'listen': '127.0.0.1:8008', 'auth': 'test:test'}
|
config = config or {'listen': '127.0.0.1:8008', 'auth': 'test:test', 'certfile': 'dumb'}
|
||||||
super(MockRestApiServer, self).__init__(MockPatroni(), config)
|
super(MockRestApiServer, self).__init__(MockPatroni(), config)
|
||||||
config['certfile'] = 'dumb'
|
|
||||||
self.reload_config(config)
|
|
||||||
Handler(MockRequest(request), ('0.0.0.0', 8080), self)
|
Handler(MockRequest(request), ('0.0.0.0', 8080), self)
|
||||||
|
|
||||||
|
|
||||||
@@ -153,8 +159,9 @@ class TestRestApiHandler(unittest.TestCase):
|
|||||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})):
|
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})):
|
||||||
MockRestApiServer(RestApiHandler, 'GET /synchronous')
|
MockRestApiServer(RestApiHandler, 'GET /synchronous')
|
||||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})):
|
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})):
|
||||||
|
MockPatroni.dcs.cluster.sync.sync_standby = ''
|
||||||
MockRestApiServer(RestApiHandler, 'GET /asynchronous')
|
MockRestApiServer(RestApiHandler, 'GET /asynchronous')
|
||||||
MockPatroni.dcs.cluster.leader.name = MockPostgresql.name
|
MockPatroni.ha.is_leader = Mock(return_value=True)
|
||||||
MockRestApiServer(RestApiHandler, 'GET /replica')
|
MockRestApiServer(RestApiHandler, 'GET /replica')
|
||||||
MockPatroni.dcs.cluster = None
|
MockPatroni.dcs.cluster = None
|
||||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
|
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
|
||||||
@@ -164,6 +171,8 @@ class TestRestApiHandler(unittest.TestCase):
|
|||||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /master'))
|
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /master'))
|
||||||
with patch.object(RestApiServer, 'query', Mock(return_value=[('', 1, '', '', '', '', False, '')])):
|
with patch.object(RestApiServer, 'query', Mock(return_value=[('', 1, '', '', '', '', False, '')])):
|
||||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
|
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
|
||||||
|
with patch.object(MockHa, 'is_standby_cluster', Mock(return_value=True)):
|
||||||
|
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
|
||||||
|
|
||||||
def test_do_OPTIONS(self):
|
def test_do_OPTIONS(self):
|
||||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'OPTIONS / HTTP/1.0'))
|
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'OPTIONS / HTTP/1.0'))
|
||||||
@@ -339,6 +348,7 @@ class TestRestApiHandler(unittest.TestCase):
|
|||||||
|
|
||||||
cluster2 = cluster.copy()
|
cluster2 = cluster.copy()
|
||||||
cluster2.leader.name = 'postgresql0'
|
cluster2.leader.name = 'postgresql0'
|
||||||
|
cluster2.is_unlocked.return_value = False
|
||||||
dcs.get_cluster.side_effect = [cluster, cluster2]
|
dcs.get_cluster.side_effect = [cluster, cluster2]
|
||||||
MockRestApiServer(RestApiHandler, request)
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
|
||||||
@@ -379,3 +389,15 @@ class TestRestApiHandler(unittest.TestCase):
|
|||||||
post = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: '
|
post = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: '
|
||||||
MockRestApiServer(RestApiHandler, post + '14\n\n{"leader":"1"}')
|
MockRestApiServer(RestApiHandler, post + '14\n\n{"leader":"1"}')
|
||||||
MockRestApiServer(RestApiHandler, post + '37\n\n{"candidate":"2","scheduled_at": "1"}')
|
MockRestApiServer(RestApiHandler, post + '37\n\n{"candidate":"2","scheduled_at": "1"}')
|
||||||
|
|
||||||
|
|
||||||
|
@patch('ssl.wrap_socket', Mock(return_value=0))
|
||||||
|
class TestRestApiServer(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_reload_config(self):
|
||||||
|
bad_config = {'listen': 'foo'}
|
||||||
|
self.assertRaises(ValueError, MockRestApiServer, None, '', bad_config)
|
||||||
|
srv = MockRestApiServer(lambda a1, a2, a3: None, '')
|
||||||
|
self.assertRaises(ValueError, srv.reload_config, bad_config)
|
||||||
|
self.assertRaises(ValueError, srv.reload_config, {})
|
||||||
|
srv.reload_config({'listen': '127.0.0.2:8008'})
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
def test_set_dynamic_configuration(self):
|
def test_set_dynamic_configuration(self):
|
||||||
with patch.object(Config, '_build_effective_configuration', Mock(side_effect=Exception)):
|
with patch.object(Config, '_build_effective_configuration', Mock(side_effect=Exception)):
|
||||||
self.assertIsNone(self.config.set_dynamic_configuration({'foo': 'bar'}))
|
self.assertIsNone(self.config.set_dynamic_configuration({'foo': 'bar'}))
|
||||||
self.assertTrue(self.config.set_dynamic_configuration({'synchronous_mode': True}))
|
self.assertTrue(self.config.set_dynamic_configuration({'synchronous_mode': True, 'standby_cluster': {}}))
|
||||||
|
|
||||||
def test_reload_local_configuration(self):
|
def test_reload_local_configuration(self):
|
||||||
os.environ.update({
|
os.environ.update({
|
||||||
@@ -70,12 +70,13 @@ class TestConfig(unittest.TestCase):
|
|||||||
self.assertRaises(Exception, config.reload_local_configuration, True)
|
self.assertRaises(Exception, config.reload_local_configuration, True)
|
||||||
self.assertTrue(config.reload_local_configuration(True))
|
self.assertTrue(config.reload_local_configuration(True))
|
||||||
self.assertTrue(config.reload_local_configuration())
|
self.assertTrue(config.reload_local_configuration())
|
||||||
|
self.assertIsNone(config.reload_local_configuration())
|
||||||
|
|
||||||
@patch('tempfile.mkstemp', Mock(return_value=[3000, 'blabla']))
|
@patch('tempfile.mkstemp', Mock(return_value=[3000, 'blabla']))
|
||||||
@patch('os.path.exists', Mock(return_value=True))
|
@patch('os.path.exists', Mock(return_value=True))
|
||||||
@patch('os.remove', Mock(side_effect=IOError))
|
@patch('os.remove', Mock(side_effect=IOError))
|
||||||
@patch('os.close', Mock(side_effect=IOError))
|
@patch('os.close', Mock(side_effect=IOError))
|
||||||
@patch('os.rename', Mock(return_value=None))
|
@patch('shutil.move', Mock(return_value=None))
|
||||||
@patch('json.dump', Mock())
|
@patch('json.dump', Mock())
|
||||||
def test_save_cache(self):
|
def test_save_cache(self):
|
||||||
self.config.set_dynamic_configuration({'ttl': 30, 'postgresql': {'foo': 'bar'}})
|
self.config.set_dynamic_configuration({'ttl': 30, 'postgresql': {'foo': 'bar'}})
|
||||||
|
|||||||
+26
-6
@@ -62,7 +62,7 @@ class TestHTTPClient(unittest.TestCase):
|
|||||||
|
|
||||||
def test_put(self):
|
def test_put(self):
|
||||||
self.client.put(Mock(), '/v1/session/create')
|
self.client.put(Mock(), '/v1/session/create')
|
||||||
self.client.put(Mock(), '/v1/session/create', data='{"foo": "bar"}')
|
self.client.put(Mock(), '/v1/session/create', params=[], data='{"foo": "bar"}')
|
||||||
|
|
||||||
|
|
||||||
@patch.object(consul.Consul.KV, 'get', kv_get)
|
@patch.object(consul.Consul.KV, 'get', kv_get)
|
||||||
@@ -74,10 +74,12 @@ class TestConsul(unittest.TestCase):
|
|||||||
@patch.object(consul.Consul.KV, 'delete', Mock())
|
@patch.object(consul.Consul.KV, 'delete', Mock())
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
Consul({'ttl': 30, 'scope': 't', 'name': 'p', 'url': 'https://l:1', 'retry_timeout': 10,
|
Consul({'ttl': 30, 'scope': 't', 'name': 'p', 'url': 'https://l:1', 'retry_timeout': 10,
|
||||||
'verify': 'on', 'key': 'foo', 'cert': 'bar', 'cacert': 'buz', 'token': 'asd', 'dc': 'dc1'})
|
'verify': 'on', 'key': 'foo', 'cert': 'bar', 'cacert': 'buz', 'token': 'asd', 'dc': 'dc1',
|
||||||
Consul({'ttl': 30, 'scope': 't', 'name': 'p', 'url': 'https://l:1', 'retry_timeout': 10,
|
'register_service': True})
|
||||||
'verify': 'on', 'cert': 'bar', 'cacert': 'buz'})
|
Consul({'ttl': 30, 'scope': 't_', 'name': 'p', 'url': 'https://l:1', 'retry_timeout': 10,
|
||||||
self.c = Consul({'ttl': 30, 'scope': 'test', 'name': 'postgresql1', 'host': 'localhost:1', 'retry_timeout': 10})
|
'verify': 'on', 'cert': 'bar', 'cacert': 'buz', 'register_service': True})
|
||||||
|
self.c = Consul({'ttl': 30, 'scope': 'test', 'name': 'postgresql1', 'host': 'localhost:1', 'retry_timeout': 10,
|
||||||
|
'register_service': True})
|
||||||
self.c._base_path = '/service/good'
|
self.c._base_path = '/service/good'
|
||||||
self.c._load_cluster()
|
self.c._load_cluster()
|
||||||
|
|
||||||
@@ -111,12 +113,14 @@ class TestConsul(unittest.TestCase):
|
|||||||
@patch.object(consul.Consul.KV, 'delete', Mock(side_effect=[ConsulException, True, True]))
|
@patch.object(consul.Consul.KV, 'delete', Mock(side_effect=[ConsulException, True, True]))
|
||||||
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=[True, ConsulException]))
|
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=[True, ConsulException]))
|
||||||
def test_touch_member(self):
|
def test_touch_member(self):
|
||||||
|
self.c._register_service = True
|
||||||
self.c.refresh_session = Mock(return_value=True)
|
self.c.refresh_session = Mock(return_value=True)
|
||||||
self.c.touch_member({'balbla': 'blabla'})
|
self.c.touch_member({'balbla': 'blabla'})
|
||||||
self.c.touch_member({'balbla': 'blabla'})
|
self.c.touch_member({'balbla': 'blabla'})
|
||||||
self.c.touch_member({'balbla': 'blabla'})
|
self.c.touch_member({'balbla': 'blabla'})
|
||||||
self.c.refresh_session = Mock(return_value=False)
|
self.c.refresh_session = Mock(return_value=False)
|
||||||
self.c.touch_member({'balbla': 'blabla'})
|
self.c.touch_member({'conn_url': 'postgres://replicator:[email protected]:5433/postgres',
|
||||||
|
'api_url': 'http://127.0.0.1:8009/patroni'})
|
||||||
|
|
||||||
@patch.object(consul.Consul.KV, 'put', Mock(return_value=False))
|
@patch.object(consul.Consul.KV, 'put', Mock(return_value=False))
|
||||||
def test_take_leader(self):
|
def test_take_leader(self):
|
||||||
@@ -176,3 +180,19 @@ class TestConsul(unittest.TestCase):
|
|||||||
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
|
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
|
||||||
def test_set_history_value(self):
|
def test_set_history_value(self):
|
||||||
self.assertTrue(self.c.set_history_value('{}'))
|
self.assertTrue(self.c.set_history_value('{}'))
|
||||||
|
|
||||||
|
@patch.object(consul.Consul.Agent.Service, 'register', Mock(side_effect=(False, True)))
|
||||||
|
@patch.object(consul.Consul.Agent.Service, 'deregister', Mock(return_value=True))
|
||||||
|
def test_update_service(self):
|
||||||
|
d = {'role': 'replica', 'api_url': 'http://a/t', 'conn_url': 'pg://c:1', 'state': 'running'}
|
||||||
|
self.assertIsNone(self.c.update_service({}, {}))
|
||||||
|
self.assertFalse(self.c.update_service({}, d))
|
||||||
|
self.assertTrue(self.c.update_service(d, d))
|
||||||
|
self.assertIsNone(self.c.update_service(d, d))
|
||||||
|
d['state'] = 'stopped'
|
||||||
|
self.assertTrue(self.c.update_service(d, d, force=True))
|
||||||
|
d['state'] = 'unknown'
|
||||||
|
self.assertIsNone(self.c.update_service({}, d))
|
||||||
|
d['state'] = 'running'
|
||||||
|
d['role'] = 'bla'
|
||||||
|
self.assertIsNone(self.c.update_service({}, d))
|
||||||
|
|||||||
+50
-23
@@ -129,6 +129,11 @@ class TestCtl(unittest.TestCase):
|
|||||||
result = self.runner.invoke(ctl, ['switchover', 'dummy'], input='leader\nother\n\ny')
|
result = self.runner.invoke(ctl, ['switchover', 'dummy'], input='leader\nother\n\ny')
|
||||||
assert 'Switchover failed' in result.output
|
assert 'Switchover failed' in result.output
|
||||||
|
|
||||||
|
mocked.return_value.status_code = 501
|
||||||
|
mocked.return_value.text = 'Server does not support this operation'
|
||||||
|
result = self.runner.invoke(ctl, ['switchover', 'dummy'], input='leader\nother\n\ny')
|
||||||
|
assert 'Switchover failed' in result.output
|
||||||
|
|
||||||
# No members available
|
# No members available
|
||||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_only_leader
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_only_leader
|
||||||
result = self.runner.invoke(ctl, ['switchover', 'dummy'], input='leader\nother\n\ny')
|
result = self.runner.invoke(ctl, ['switchover', 'dummy'], input='leader\nother\n\ny')
|
||||||
@@ -187,24 +192,24 @@ class TestCtl(unittest.TestCase):
|
|||||||
|
|
||||||
def test_query_member(self):
|
def test_query_member(self):
|
||||||
with patch('patroni.ctl.get_cursor', Mock(return_value=MockConnect().cursor())):
|
with patch('patroni.ctl.get_cursor', Mock(return_value=MockConnect().cursor())):
|
||||||
rows = query_member(None, None, None, 'master', 'SELECT pg_is_in_recovery()', {})
|
rows = query_member(None, None, None, 'master', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||||
self.assertTrue('False' in str(rows))
|
self.assertTrue('False' in str(rows))
|
||||||
|
|
||||||
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()', {})
|
rows = query_member(None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||||
self.assertEquals(rows, (None, None))
|
self.assertEqual(rows, (None, None))
|
||||||
|
|
||||||
with patch('test_postgresql.MockCursor.execute', Mock(side_effect=OperationalError('bla'))):
|
with patch('test_postgresql.MockCursor.execute', Mock(side_effect=OperationalError('bla'))):
|
||||||
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()', {})
|
rows = query_member(None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||||
|
|
||||||
with patch('patroni.ctl.get_cursor', Mock(return_value=None)):
|
with patch('patroni.ctl.get_cursor', Mock(return_value=None)):
|
||||||
rows = query_member(None, None, None, None, 'SELECT pg_is_in_recovery()', {})
|
rows = query_member(None, None, None, None, 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||||
self.assertTrue('No connection to' in str(rows))
|
self.assertTrue('No connection to' in str(rows))
|
||||||
|
|
||||||
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()', {})
|
rows = query_member(None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||||
self.assertTrue('No connection to' in str(rows))
|
self.assertTrue('No connection to' in str(rows))
|
||||||
|
|
||||||
with patch('patroni.ctl.get_cursor', Mock(side_effect=OperationalError('bla'))):
|
with patch('patroni.ctl.get_cursor', Mock(side_effect=OperationalError('bla'))):
|
||||||
rows = query_member(None, None, None, 'replica', 'SELECT pg_is_in_recovery()', {})
|
rows = query_member(None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||||
|
|
||||||
@patch('patroni.ctl.get_dcs')
|
@patch('patroni.ctl.get_dcs')
|
||||||
def test_dsn(self, mock_get_dcs):
|
def test_dsn(self, mock_get_dcs):
|
||||||
@@ -220,6 +225,22 @@ class TestCtl(unittest.TestCase):
|
|||||||
result = self.runner.invoke(ctl, ['dsn', 'alpha', '--member', 'dummy'])
|
result = self.runner.invoke(ctl, ['dsn', 'alpha', '--member', 'dummy'])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
@patch('requests.post')
|
||||||
|
@patch('patroni.ctl.get_dcs')
|
||||||
|
def test_reload(self, mock_get_dcs, mock_post):
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
|
|
||||||
|
result = self.runner.invoke(ctl, ['reload', 'alpha'], input='y')
|
||||||
|
assert 'Failed: reload for member' in result.output
|
||||||
|
|
||||||
|
mock_post.return_value.status_code = 200
|
||||||
|
result = self.runner.invoke(ctl, ['reload', 'alpha'], input='y')
|
||||||
|
assert 'No changes to apply on member' in result.output
|
||||||
|
|
||||||
|
mock_post.return_value.status_code = 202
|
||||||
|
result = self.runner.invoke(ctl, ['reload', 'alpha'], input='y')
|
||||||
|
assert 'Reload request received for member' in result.output
|
||||||
|
|
||||||
@patch('requests.post', requests_get)
|
@patch('requests.post', requests_get)
|
||||||
@patch('patroni.ctl.get_dcs')
|
@patch('patroni.ctl.get_dcs')
|
||||||
def test_restart_reinit(self, mock_get_dcs):
|
def test_restart_reinit(self, mock_get_dcs):
|
||||||
@@ -323,8 +344,14 @@ class TestCtl(unittest.TestCase):
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
@patch('requests.post', Mock(side_effect=requests.exceptions.ConnectionError('foo')))
|
@patch('requests.post', Mock(side_effect=requests.exceptions.ConnectionError('foo')))
|
||||||
def test_request_patroni(self):
|
@patch('click.get_current_context')
|
||||||
|
def test_request_patroni(self, mock_context):
|
||||||
member = get_cluster_initialized_with_leader().leader.member
|
member = get_cluster_initialized_with_leader().leader.member
|
||||||
|
|
||||||
|
mock_context.return_value.obj = {'ctl': {'cacert': 'cert.pem'}}
|
||||||
|
self.assertRaises(requests.exceptions.ConnectionError, request_patroni, member, 'post', 'dummy', {})
|
||||||
|
|
||||||
|
mock_context.return_value.obj = {'ctl': {'insecure': True}}
|
||||||
self.assertRaises(requests.exceptions.ConnectionError, request_patroni, member, 'post', 'dummy', {})
|
self.assertRaises(requests.exceptions.ConnectionError, request_patroni, member, 'post', 'dummy', {})
|
||||||
|
|
||||||
def test_ctl(self):
|
def test_ctl(self):
|
||||||
@@ -337,20 +364,20 @@ class TestCtl(unittest.TestCase):
|
|||||||
self.assertIsNone(get_any_member(get_cluster_initialized_without_leader(), role='master'))
|
self.assertIsNone(get_any_member(get_cluster_initialized_without_leader(), role='master'))
|
||||||
|
|
||||||
m = get_any_member(get_cluster_initialized_with_leader(), role='master')
|
m = get_any_member(get_cluster_initialized_with_leader(), role='master')
|
||||||
self.assertEquals(m.name, 'leader')
|
self.assertEqual(m.name, 'leader')
|
||||||
|
|
||||||
def test_get_all_members(self):
|
def test_get_all_members(self):
|
||||||
self.assertEquals(list(get_all_members(get_cluster_initialized_without_leader(), role='master')), [])
|
self.assertEqual(list(get_all_members(get_cluster_initialized_without_leader(), role='master')), [])
|
||||||
|
|
||||||
r = list(get_all_members(get_cluster_initialized_with_leader(), role='master'))
|
r = list(get_all_members(get_cluster_initialized_with_leader(), role='master'))
|
||||||
self.assertEquals(len(r), 1)
|
self.assertEqual(len(r), 1)
|
||||||
self.assertEquals(r[0].name, 'leader')
|
self.assertEqual(r[0].name, 'leader')
|
||||||
|
|
||||||
r = list(get_all_members(get_cluster_initialized_with_leader(), role='replica'))
|
r = list(get_all_members(get_cluster_initialized_with_leader(), role='replica'))
|
||||||
self.assertEquals(len(r), 1)
|
self.assertEqual(len(r), 1)
|
||||||
self.assertEquals(r[0].name, 'other')
|
self.assertEqual(r[0].name, 'other')
|
||||||
|
|
||||||
self.assertEquals(len(list(get_all_members(get_cluster_initialized_without_leader(), role='replica'))), 2)
|
self.assertEqual(len(list(get_all_members(get_cluster_initialized_without_leader(), role='replica'))), 2)
|
||||||
|
|
||||||
@patch('patroni.ctl.get_dcs')
|
@patch('patroni.ctl.get_dcs')
|
||||||
def test_members(self, mock_get_dcs):
|
def test_members(self, mock_get_dcs):
|
||||||
@@ -472,23 +499,23 @@ class TestCtl(unittest.TestCase):
|
|||||||
after_editing, changed_config = apply_config_changes(before_editing, config,
|
after_editing, changed_config = apply_config_changes(before_editing, config,
|
||||||
["postgresql.parameters.work_mem = 5MB",
|
["postgresql.parameters.work_mem = 5MB",
|
||||||
"ttl=15", "postgresql.use_pg_rewind=off", 'a.b=c'])
|
"ttl=15", "postgresql.use_pg_rewind=off", 'a.b=c'])
|
||||||
self.assertEquals(changed_config, {"a": {"b": "c"}, "postgresql": {"parameters": {"work_mem": "5MB"},
|
self.assertEqual(changed_config, {"a": {"b": "c"}, "postgresql": {"parameters": {"work_mem": "5MB"},
|
||||||
"use_pg_rewind": False}, "ttl": 15})
|
"use_pg_rewind": False}, "ttl": 15})
|
||||||
|
|
||||||
# postgresql.parameters namespace is flattened
|
# postgresql.parameters namespace is flattened
|
||||||
after_editing, changed_config = apply_config_changes(before_editing, config,
|
after_editing, changed_config = apply_config_changes(before_editing, config,
|
||||||
["postgresql.parameters.work_mem.sub = x"])
|
["postgresql.parameters.work_mem.sub = x"])
|
||||||
self.assertEquals(changed_config, {"postgresql": {"parameters": {"work_mem": "4MB", "work_mem.sub": "x"},
|
self.assertEqual(changed_config, {"postgresql": {"parameters": {"work_mem": "4MB", "work_mem.sub": "x"},
|
||||||
"use_pg_rewind": True}, "ttl": 30})
|
"use_pg_rewind": True}, "ttl": 30})
|
||||||
|
|
||||||
# Setting to null deletes
|
# Setting to null deletes
|
||||||
after_editing, changed_config = apply_config_changes(before_editing, config,
|
after_editing, changed_config = apply_config_changes(before_editing, config,
|
||||||
["postgresql.parameters.work_mem=null"])
|
["postgresql.parameters.work_mem=null"])
|
||||||
self.assertEquals(changed_config, {"postgresql": {"use_pg_rewind": True}, "ttl": 30})
|
self.assertEqual(changed_config, {"postgresql": {"use_pg_rewind": True}, "ttl": 30})
|
||||||
after_editing, changed_config = apply_config_changes(before_editing, config,
|
after_editing, changed_config = apply_config_changes(before_editing, config,
|
||||||
["postgresql.use_pg_rewind=null",
|
["postgresql.use_pg_rewind=null",
|
||||||
"postgresql.parameters.work_mem=null"])
|
"postgresql.parameters.work_mem=null"])
|
||||||
self.assertEquals(changed_config, {"ttl": 30})
|
self.assertEqual(changed_config, {"ttl": 30})
|
||||||
|
|
||||||
self.assertRaises(PatroniCtlException, apply_config_changes, before_editing, config, ['a'])
|
self.assertRaises(PatroniCtlException, apply_config_changes, before_editing, config, ['a'])
|
||||||
|
|
||||||
@@ -545,5 +572,5 @@ class TestCtl(unittest.TestCase):
|
|||||||
assert 'failed to get version' in result.output
|
assert 'failed to get version' in result.output
|
||||||
|
|
||||||
def test_format_pg_version(self):
|
def test_format_pg_version(self):
|
||||||
self.assertEquals(format_pg_version(100001), '10.1')
|
self.assertEqual(format_pg_version(100001), '10.1')
|
||||||
self.assertEquals(format_pg_version(90605), '9.6.5')
|
self.assertEqual(format_pg_version(90605), '9.6.5')
|
||||||
|
|||||||
+8
-5
@@ -90,7 +90,7 @@ def etcd_read(self, key, **kwargs):
|
|||||||
raise etcd.EtcdKeyNotFound
|
raise etcd.EtcdKeyNotFound
|
||||||
|
|
||||||
response = {"action": "get", "node": {"key": "/service/batman5", "dir": True, "nodes": [
|
response = {"action": "get", "node": {"key": "/service/batman5", "dir": True, "nodes": [
|
||||||
{"key": "/service/batman5/config", "value": '{"foo": "bar"}',
|
{"key": "/service/batman5/config", "value": '{"synchronous_mode": 0}',
|
||||||
"modifiedIndex": 1582, "createdIndex": 1582},
|
"modifiedIndex": 1582, "createdIndex": 1582},
|
||||||
{"key": "/service/batman5/failover", "value": "",
|
{"key": "/service/batman5/failover", "value": "",
|
||||||
"modifiedIndex": 1582, "createdIndex": 1582},
|
"modifiedIndex": 1582, "createdIndex": 1582},
|
||||||
@@ -207,6 +207,7 @@ class TestClient(unittest.TestCase):
|
|||||||
mock_machines.__get__ = Mock(return_value=['http://localhost:2379'])
|
mock_machines.__get__ = Mock(return_value=['http://localhost:2379'])
|
||||||
self.client._machines_cache_updated = 0
|
self.client._machines_cache_updated = 0
|
||||||
self.client.api_execute('/', 'POST', timeout=0)
|
self.client.api_execute('/', 'POST', timeout=0)
|
||||||
|
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, '/', '')
|
self.assertRaises(etcd.EtcdException, self.client.api_execute, '/', '')
|
||||||
self.client._update_machines_cache = True
|
self.client._update_machines_cache = True
|
||||||
@@ -214,8 +215,8 @@ class TestClient(unittest.TestCase):
|
|||||||
self.assertRaises(etcd.EtcdException, self.client.api_execute, '/', 'GET')
|
self.assertRaises(etcd.EtcdException, self.client.api_execute, '/', 'GET')
|
||||||
|
|
||||||
def test_get_srv_record(self):
|
def test_get_srv_record(self):
|
||||||
self.assertEquals(self.client.get_srv_record('_etcd-server._tcp.blabla'), [])
|
self.assertEqual(self.client.get_srv_record('_etcd-server._tcp.blabla'), [])
|
||||||
self.assertEquals(self.client.get_srv_record('_etcd-server._tcp.exception'), [])
|
self.assertEqual(self.client.get_srv_record('_etcd-server._tcp.exception'), [])
|
||||||
|
|
||||||
def test__get_machines_cache_from_srv(self):
|
def test__get_machines_cache_from_srv(self):
|
||||||
self.client._get_machines_cache_from_srv('foobar')
|
self.client._get_machines_cache_from_srv('foobar')
|
||||||
@@ -258,7 +259,7 @@ class TestEtcd(unittest.TestCase):
|
|||||||
'host': 'localhost:2379', 'scope': 'test', 'name': 'foo'})
|
'host': 'localhost:2379', 'scope': 'test', 'name': 'foo'})
|
||||||
|
|
||||||
def test_base_path(self):
|
def test_base_path(self):
|
||||||
self.assertEquals(self.etcd._base_path, '/patroni/test')
|
self.assertEqual(self.etcd._base_path, '/patroni/test')
|
||||||
|
|
||||||
@patch('dns.resolver.query', dns_query)
|
@patch('dns.resolver.query', dns_query)
|
||||||
def test_get_etcd_client(self):
|
def test_get_etcd_client(self):
|
||||||
@@ -275,7 +276,9 @@ class TestEtcd(unittest.TestCase):
|
|||||||
{'hosts': 'foo:4001,bar', 'retry_timeout': 10})
|
{'hosts': 'foo:4001,bar', 'retry_timeout': 10})
|
||||||
|
|
||||||
def test_get_cluster(self):
|
def test_get_cluster(self):
|
||||||
self.assertIsInstance(self.etcd.get_cluster(), Cluster)
|
cluster = self.etcd.get_cluster()
|
||||||
|
self.assertIsInstance(cluster, Cluster)
|
||||||
|
self.assertFalse(cluster.is_synchronous_mode())
|
||||||
self.etcd._base_path = '/service/nocluster'
|
self.etcd._base_path = '/service/nocluster'
|
||||||
cluster = self.etcd.get_cluster()
|
cluster = self.etcd.get_cluster()
|
||||||
self.assertIsInstance(cluster, Cluster)
|
self.assertIsInstance(cluster, Cluster)
|
||||||
|
|||||||
+236
-123
@@ -2,6 +2,7 @@ import datetime
|
|||||||
import etcd
|
import etcd
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
|
import sys
|
||||||
|
|
||||||
from mock import Mock, MagicMock, PropertyMock, patch
|
from mock import Mock, MagicMock, PropertyMock, patch
|
||||||
from patroni.config import Config
|
from patroni.config import Config
|
||||||
@@ -15,6 +16,8 @@ from patroni.utils import tzutc
|
|||||||
from test_etcd import socket_getaddrinfo, etcd_read, etcd_write, requests_get
|
from test_etcd import socket_getaddrinfo, etcd_read, etcd_write, requests_get
|
||||||
from test_postgresql import psycopg2_connect, MockPostmaster
|
from test_postgresql import psycopg2_connect, MockPostmaster
|
||||||
|
|
||||||
|
SYSID = '12345678901'
|
||||||
|
|
||||||
|
|
||||||
def true(*args, **kwargs):
|
def true(*args, **kwargs):
|
||||||
return True
|
return True
|
||||||
@@ -24,16 +27,17 @@ def false(*args, **kwargs):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_cluster(initialize, leader, members, failover, sync):
|
def get_cluster(initialize, leader, members, failover, sync, cluster_config=None):
|
||||||
history = TimelineHistory(1, [(1, 67197376, 'no recovery target specified', datetime.datetime.now().isoformat())])
|
history = TimelineHistory(1, [(1, 67197376, 'no recovery target specified', datetime.datetime.now().isoformat())])
|
||||||
return Cluster(initialize, ClusterConfig(1, {1: 2}, 1), leader, 10, members, failover, sync, history)
|
cluster_config = cluster_config or ClusterConfig(1, {1: 2}, 1)
|
||||||
|
return Cluster(initialize, cluster_config, leader, 10, members, failover, sync, history)
|
||||||
|
|
||||||
|
|
||||||
def get_cluster_not_initialized_without_leader():
|
def get_cluster_not_initialized_without_leader(cluster_config=None):
|
||||||
return get_cluster(None, None, [], None, SyncState(None, None, None))
|
return get_cluster(None, None, [], None, SyncState(None, None, None), cluster_config)
|
||||||
|
|
||||||
|
|
||||||
def get_cluster_initialized_without_leader(leader=False, failover=None, sync=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',
|
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})
|
'api_url': 'http://127.0.0.1:8008/patroni', 'xlog_location': 4})
|
||||||
leader = Leader(0, 0, m1) if leader else None
|
leader = Leader(0, 0, m1) if leader else None
|
||||||
@@ -45,16 +49,27 @@ def get_cluster_initialized_without_leader(leader=False, failover=None, sync=Non
|
|||||||
'scheduled_restart': {'schedule': "2100-01-01 10:53:07.560445+00:00",
|
'scheduled_restart': {'schedule': "2100-01-01 10:53:07.560445+00:00",
|
||||||
'postgres_version': '99.0.0'}})
|
'postgres_version': '99.0.0'}})
|
||||||
syncstate = SyncState(0 if sync else None, sync and sync[0], sync and sync[1])
|
syncstate = SyncState(0 if sync else None, sync and sync[0], sync and sync[1])
|
||||||
return get_cluster(True, leader, [m1, m2], failover, syncstate)
|
return get_cluster(SYSID, leader, [m1, m2], failover, syncstate, cluster_config)
|
||||||
|
|
||||||
|
|
||||||
def get_cluster_initialized_with_leader(failover=None, sync=None):
|
def get_cluster_initialized_with_leader(failover=None, sync=None):
|
||||||
return get_cluster_initialized_without_leader(leader=True, failover=failover, sync=sync)
|
return get_cluster_initialized_without_leader(leader=True, failover=failover, sync=sync)
|
||||||
|
|
||||||
|
|
||||||
def get_cluster_initialized_with_only_leader(failover=None):
|
def get_cluster_initialized_with_only_leader(failover=None, cluster_config=None):
|
||||||
leader = get_cluster_initialized_without_leader(leader=True, failover=failover).leader
|
leader = get_cluster_initialized_without_leader(leader=True, failover=failover).leader
|
||||||
return get_cluster(True, leader, [leader], failover, None)
|
return get_cluster(True, leader, [leader], failover, None, cluster_config)
|
||||||
|
|
||||||
|
|
||||||
|
def get_standby_cluster_initialized_with_only_leader(failover=None, sync=None):
|
||||||
|
return get_cluster_initialized_with_only_leader(
|
||||||
|
cluster_config=ClusterConfig(1, {
|
||||||
|
"standby_cluster": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 5432,
|
||||||
|
"primary_slot_name": "",
|
||||||
|
}}, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_node_status(reachable=True, in_recovery=True, wal_position=10, nofailover=False, watchdog_failed=False):
|
def get_node_status(reachable=True, in_recovery=True, wal_position=10, nofailover=False, watchdog_failed=False):
|
||||||
@@ -95,6 +110,10 @@ zookeeper:
|
|||||||
hosts: [localhost]
|
hosts: [localhost]
|
||||||
port: 8181
|
port: 8181
|
||||||
"""
|
"""
|
||||||
|
# We rely on sys.argv in Config, so it's necessary to reset
|
||||||
|
# all the extra values that are coming from py.test
|
||||||
|
sys.argv = sys.argv[:1]
|
||||||
|
|
||||||
self.config = Config()
|
self.config = Config()
|
||||||
self.postgresql = p
|
self.postgresql = p
|
||||||
self.dcs = d
|
self.dcs = d
|
||||||
@@ -120,14 +139,13 @@ def run_async(self, func, args=()):
|
|||||||
@patch.object(Postgresql, '_cluster_info_state_get', Mock(return_value=3))
|
@patch.object(Postgresql, '_cluster_info_state_get', Mock(return_value=3))
|
||||||
@patch.object(Postgresql, 'call_nowait', Mock(return_value=True))
|
@patch.object(Postgresql, 'call_nowait', Mock(return_value=True))
|
||||||
@patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False))
|
@patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False))
|
||||||
@patch.object(Postgresql, 'controldata', Mock(return_value={'Database system identifier': '1234567890'}))
|
@patch.object(Postgresql, 'controldata', Mock(return_value={'Database system identifier': SYSID}))
|
||||||
@patch.object(Postgresql, 'sync_replication_slots', Mock())
|
@patch.object(Postgresql, 'sync_replication_slots', Mock())
|
||||||
@patch.object(Postgresql, 'write_pg_hba', Mock())
|
@patch.object(Postgresql, 'write_pg_hba', Mock())
|
||||||
@patch.object(Postgresql, 'write_pgpass', Mock(return_value={}))
|
@patch.object(Postgresql, 'write_pgpass', Mock(return_value={}))
|
||||||
@patch.object(Postgresql, 'write_recovery_conf', Mock())
|
@patch.object(Postgresql, 'write_recovery_conf', Mock())
|
||||||
@patch.object(Postgresql, 'query', Mock())
|
@patch.object(Postgresql, 'query', Mock())
|
||||||
@patch.object(Postgresql, 'checkpoint', Mock())
|
@patch.object(Postgresql, 'checkpoint', Mock())
|
||||||
@patch.object(Postgresql, 'call_nowait', Mock())
|
|
||||||
@patch.object(Postgresql, 'cancellable_subprocess_call', Mock(return_value=0))
|
@patch.object(Postgresql, 'cancellable_subprocess_call', Mock(return_value=0))
|
||||||
@patch.object(etcd.Client, 'write', etcd_write)
|
@patch.object(etcd.Client, 'write', etcd_write)
|
||||||
@patch.object(etcd.Client, 'read', etcd_read)
|
@patch.object(etcd.Client, 'read', etcd_read)
|
||||||
@@ -161,7 +179,7 @@ class TestHa(unittest.TestCase):
|
|||||||
'name': 'foo', 'retry_timeout': 10}})
|
'name': 'foo', 'retry_timeout': 10}})
|
||||||
self.ha = Ha(MockPatroni(self.p, self.e))
|
self.ha = Ha(MockPatroni(self.p, self.e))
|
||||||
self.ha.old_cluster = self.e.get_cluster()
|
self.ha.old_cluster = self.e.get_cluster()
|
||||||
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
self.ha.cluster = get_cluster_initialized_without_leader()
|
||||||
self.ha.load_cluster_from_dcs = Mock()
|
self.ha.load_cluster_from_dcs = Mock()
|
||||||
|
|
||||||
def test_update_lock(self):
|
def test_update_lock(self):
|
||||||
@@ -173,50 +191,62 @@ class TestHa(unittest.TestCase):
|
|||||||
self.p.replica_cached_timeline = Mock(side_effect=Exception)
|
self.p.replica_cached_timeline = Mock(side_effect=Exception)
|
||||||
self.ha.touch_member()
|
self.ha.touch_member()
|
||||||
|
|
||||||
|
def test_is_leader(self):
|
||||||
|
self.assertFalse(self.ha.is_leader())
|
||||||
|
|
||||||
def test_start_as_replica(self):
|
def test_start_as_replica(self):
|
||||||
self.p.is_healthy = false
|
self.p.is_healthy = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'starting as a secondary')
|
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
|
||||||
|
|
||||||
|
@patch('patroni.dcs.etcd.Etcd.initialize', return_value=True)
|
||||||
|
def test_start_as_standby_leader(self, initialize):
|
||||||
|
self.p.data_directory_empty = true
|
||||||
|
self.ha.cluster = get_cluster_not_initialized_without_leader(cluster_config=ClusterConfig(0, {}, 0))
|
||||||
|
self.ha.cluster.is_unlocked = true
|
||||||
|
self.ha.patroni.config._dynamic_configuration = {"standby_cluster": {"port": 5432}}
|
||||||
|
self.assertEqual(self.ha.run_cycle(), 'trying to bootstrap a new standby leader')
|
||||||
|
|
||||||
|
@patch.object(Cluster, 'get_clone_member',
|
||||||
|
Mock(return_value=Member(0, 'test', 1, {'api_url': 'http://127.0.0.1:8011/patroni'})))
|
||||||
|
@patch.object(Postgresql, 'create_replica', Mock(return_value=0))
|
||||||
|
def test_start_as_cascade_replica_in_standby_cluster(self):
|
||||||
|
self.p.data_directory_empty = true
|
||||||
|
self.ha.cluster = get_standby_cluster_initialized_with_only_leader()
|
||||||
|
self.ha.cluster.is_unlocked = false
|
||||||
|
self.assertEqual(self.ha.run_cycle(), "trying to bootstrap from replica 'test'")
|
||||||
|
|
||||||
def test_recover_replica_failed(self):
|
def test_recover_replica_failed(self):
|
||||||
self.p.controldata = lambda: {'Database cluster state': 'in recovery'}
|
self.p.controldata = lambda: {'Database cluster state': 'in recovery', 'Database system identifier': SYSID}
|
||||||
self.p.is_running = false
|
self.p.is_running = false
|
||||||
self.p.follow = false
|
self.p.follow = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'starting as a secondary')
|
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
|
||||||
self.assertEquals(self.ha.run_cycle(), 'failed to start postgres')
|
self.assertEqual(self.ha.run_cycle(), 'failed to start postgres')
|
||||||
|
|
||||||
def test_recover_former_master(self):
|
def test_recover_former_master(self):
|
||||||
self.p.follow = false
|
self.p.follow = false
|
||||||
self.p.is_running = false
|
self.p.is_running = false
|
||||||
self.p.name = 'leader'
|
self.p.name = 'leader'
|
||||||
self.p.set_role('master')
|
self.p.set_role('master')
|
||||||
self.p.controldata = lambda: {'Database cluster state': 'shut down'}
|
self.p.controldata = lambda: {'Database cluster state': 'shut down', 'Database system identifier': SYSID}
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
self.assertEquals(self.ha.run_cycle(), 'starting as readonly because i had the session lock')
|
self.assertEqual(self.ha.run_cycle(), 'starting as readonly because i had the session lock')
|
||||||
|
|
||||||
@patch.object(Postgresql, 'fix_cluster_state', Mock())
|
@patch.object(Postgresql, 'fix_cluster_state', Mock())
|
||||||
def test_crash_recovery(self):
|
def test_crash_recovery(self):
|
||||||
self.p.is_running = false
|
self.p.is_running = false
|
||||||
self.p.controldata = lambda: {'Database cluster state': 'in production'}
|
self.p.controldata = lambda: {'Database cluster state': 'in production', 'Database system identifier': SYSID}
|
||||||
self.assertEquals(self.ha.run_cycle(), 'doing crash recovery in a single user mode')
|
self.assertEqual(self.ha.run_cycle(), 'doing crash recovery in a single user mode')
|
||||||
|
|
||||||
@patch.object(Postgresql, 'rewind_needed_and_possible', Mock(return_value=True))
|
@patch.object(Postgresql, 'rewind_needed_and_possible', Mock(return_value=True))
|
||||||
def test_recover_with_rewind(self):
|
def test_recover_with_rewind(self):
|
||||||
self.p.is_running = false
|
self.p.is_running = false
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
self.assertEquals(self.ha.run_cycle(), 'running pg_rewind from leader')
|
self.assertEqual(self.ha.run_cycle(), 'running pg_rewind from leader')
|
||||||
|
|
||||||
@patch.object(Postgresql, 'can_rewind', PropertyMock(return_value=True))
|
|
||||||
@patch.object(Postgresql, 'fix_cluster_state', Mock())
|
|
||||||
def test_single_user_after_recover_failed(self):
|
|
||||||
self.p.controldata = lambda: {'Database cluster state': 'in recovery'}
|
|
||||||
self.p.is_running = false
|
|
||||||
self.p.follow = false
|
|
||||||
self.assertEquals(self.ha.run_cycle(), 'starting as a secondary')
|
|
||||||
self.assertEquals(self.ha.run_cycle(), 'fixing cluster state in a single user mode')
|
|
||||||
|
|
||||||
@patch('sys.exit', return_value=1)
|
@patch('sys.exit', return_value=1)
|
||||||
@patch('patroni.ha.Ha.sysid_valid', MagicMock(return_value=True))
|
@patch('patroni.ha.Ha.sysid_valid', MagicMock(return_value=True))
|
||||||
def test_sysid_no_match(self, exit_mock):
|
def test_sysid_no_match(self, exit_mock):
|
||||||
|
self.p.controldata = lambda: {'Database cluster state': 'in recovery', 'Database system identifier': '123'}
|
||||||
self.ha.run_cycle()
|
self.ha.run_cycle()
|
||||||
exit_mock.assert_called_once_with(1)
|
exit_mock.assert_called_once_with(1)
|
||||||
|
|
||||||
@@ -225,129 +255,130 @@ class TestHa(unittest.TestCase):
|
|||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.p.is_healthy = true
|
self.p.is_healthy = true
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.p.controldata = lambda: {'Database cluster state': 'in production'}
|
self.p.controldata = lambda: {'Database cluster state': 'in production', 'Database system identifier': SYSID}
|
||||||
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader because i had the session lock')
|
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader because i had the session lock')
|
||||||
|
|
||||||
@patch('psycopg2.connect', psycopg2_connect)
|
@patch('psycopg2.connect', psycopg2_connect)
|
||||||
def test_acquire_lock_as_master(self):
|
def test_acquire_lock_as_master(self):
|
||||||
self.assertEquals(self.ha.run_cycle(), 'acquired session lock as a leader')
|
self.assertEqual(self.ha.run_cycle(), 'acquired session lock as a leader')
|
||||||
|
|
||||||
def test_promoted_by_acquiring_lock(self):
|
def test_promoted_by_acquiring_lock(self):
|
||||||
self.ha.is_healthiest_node = true
|
self.ha.is_healthiest_node = true
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||||
|
|
||||||
def test_long_promote(self):
|
def test_long_promote(self):
|
||||||
self.ha.cluster.is_unlocked = false
|
self.ha.cluster.is_unlocked = false
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.p.set_role('master')
|
self.p.set_role('master')
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
|
|
||||||
def test_demote_after_failing_to_obtain_lock(self):
|
def test_demote_after_failing_to_obtain_lock(self):
|
||||||
self.ha.acquire_lock = false
|
self.ha.acquire_lock = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'demoted self after trying and failing to obtain lock')
|
self.assertEqual(self.ha.run_cycle(), 'demoted self after trying and failing to obtain lock')
|
||||||
|
|
||||||
def test_follow_new_leader_after_failing_to_obtain_lock(self):
|
def test_follow_new_leader_after_failing_to_obtain_lock(self):
|
||||||
self.ha.is_healthiest_node = true
|
self.ha.is_healthiest_node = true
|
||||||
self.ha.acquire_lock = false
|
self.ha.acquire_lock = false
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'following new leader after trying and failing to obtain lock')
|
self.assertEqual(self.ha.run_cycle(), 'following new leader after trying and failing to obtain lock')
|
||||||
|
|
||||||
def test_demote_because_not_healthiest(self):
|
def test_demote_because_not_healthiest(self):
|
||||||
self.ha.is_healthiest_node = false
|
self.ha.is_healthiest_node = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'demoting self because i am not the healthiest node')
|
self.assertEqual(self.ha.run_cycle(), 'demoting self because i am not the healthiest node')
|
||||||
|
|
||||||
def test_follow_new_leader_because_not_healthiest(self):
|
def test_follow_new_leader_because_not_healthiest(self):
|
||||||
self.ha.is_healthiest_node = false
|
self.ha.is_healthiest_node = false
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
||||||
|
|
||||||
def test_promote_because_have_lock(self):
|
def test_promote_because_have_lock(self):
|
||||||
self.ha.cluster.is_unlocked = false
|
self.ha.cluster.is_unlocked = false
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader because i had the session lock')
|
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader because i had the session lock')
|
||||||
|
|
||||||
def test_promote_without_watchdog(self):
|
def test_promote_without_watchdog(self):
|
||||||
self.ha.cluster.is_unlocked = false
|
self.ha.cluster.is_unlocked = false
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.p.is_leader = true
|
self.p.is_leader = true
|
||||||
with patch.object(Watchdog, 'activate', Mock(return_value=False)):
|
with patch.object(Watchdog, 'activate', Mock(return_value=False)):
|
||||||
self.assertEquals(self.ha.run_cycle(), 'Demoting self because watchdog could not be activated')
|
self.assertEqual(self.ha.run_cycle(), 'Demoting self because watchdog could not be activated')
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'Not promoting self because watchdog could not be activated')
|
self.assertEqual(self.ha.run_cycle(), 'Not promoting self because watchdog could not be activated')
|
||||||
|
|
||||||
def test_leader_with_lock(self):
|
def test_leader_with_lock(self):
|
||||||
|
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||||
self.ha.cluster.is_unlocked = false
|
self.ha.cluster.is_unlocked = false
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
|
|
||||||
def test_demote_because_not_having_lock(self):
|
def test_demote_because_not_having_lock(self):
|
||||||
self.ha.cluster.is_unlocked = false
|
self.ha.cluster.is_unlocked = false
|
||||||
with patch.object(Watchdog, 'is_running', PropertyMock(return_value=True)):
|
with patch.object(Watchdog, 'is_running', PropertyMock(return_value=True)):
|
||||||
self.assertEquals(self.ha.run_cycle(), 'demoting self because i do not have the lock and i was a leader')
|
self.assertEqual(self.ha.run_cycle(), 'demoting self because i do not have the lock and i was a leader')
|
||||||
|
|
||||||
def test_demote_because_update_lock_failed(self):
|
def test_demote_because_update_lock_failed(self):
|
||||||
self.ha.cluster.is_unlocked = false
|
self.ha.cluster.is_unlocked = false
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.ha.update_lock = false
|
self.ha.update_lock = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'demoted self because failed to update leader lock in DCS')
|
self.assertEqual(self.ha.run_cycle(), 'demoted self because failed to update leader lock in DCS')
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'not promoting because failed to update leader lock in DCS')
|
self.assertEqual(self.ha.run_cycle(), 'not promoting because failed to update leader lock in DCS')
|
||||||
|
|
||||||
def test_follow(self):
|
def test_follow(self):
|
||||||
self.ha.cluster.is_unlocked = false
|
self.ha.cluster.is_unlocked = false
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
||||||
self.ha.patroni.replicatefrom = "foo"
|
self.ha.patroni.replicatefrom = "foo"
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
||||||
|
|
||||||
def test_follow_in_pause(self):
|
def test_follow_in_pause(self):
|
||||||
self.ha.cluster.is_unlocked = false
|
self.ha.cluster.is_unlocked = false
|
||||||
self.ha.is_paused = true
|
self.ha.is_paused = true
|
||||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
|
self.assertEqual(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: no action')
|
self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action')
|
||||||
|
|
||||||
@patch.object(Postgresql, 'rewind_needed_and_possible', Mock(return_value=True))
|
@patch.object(Postgresql, 'rewind_needed_and_possible', Mock(return_value=True))
|
||||||
def test_follow_triggers_rewind(self):
|
def test_follow_triggers_rewind(self):
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.p.trigger_check_diverged_lsn()
|
self.p.trigger_check_diverged_lsn()
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
self.assertEquals(self.ha.run_cycle(), 'running pg_rewind from leader')
|
self.assertEqual(self.ha.run_cycle(), 'running pg_rewind from leader')
|
||||||
|
|
||||||
def test_no_etcd_connection_master_demote(self):
|
def test_no_etcd_connection_master_demote(self):
|
||||||
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
||||||
self.assertEquals(self.ha.run_cycle(), 'demoted self because DCS is not accessible and i was a leader')
|
self.assertEqual(self.ha.run_cycle(), 'demoted self because DCS is not accessible and i was a leader')
|
||||||
|
|
||||||
@patch('time.sleep', Mock())
|
@patch('time.sleep', Mock())
|
||||||
def test_bootstrap_from_another_member(self):
|
def test_bootstrap_from_another_member(self):
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
self.assertEquals(self.ha.bootstrap(), 'trying to bootstrap from replica \'other\'')
|
self.assertEqual(self.ha.bootstrap(), 'trying to bootstrap from replica \'other\'')
|
||||||
|
|
||||||
def test_bootstrap_waiting_for_leader(self):
|
def test_bootstrap_waiting_for_leader(self):
|
||||||
self.ha.cluster = get_cluster_initialized_without_leader()
|
self.ha.cluster = get_cluster_initialized_without_leader()
|
||||||
self.assertEquals(self.ha.bootstrap(), 'waiting for leader to bootstrap')
|
self.assertEqual(self.ha.bootstrap(), 'waiting for leader to bootstrap')
|
||||||
|
|
||||||
def test_bootstrap_without_leader(self):
|
def test_bootstrap_without_leader(self):
|
||||||
self.ha.cluster = get_cluster_initialized_without_leader()
|
self.ha.cluster = get_cluster_initialized_without_leader()
|
||||||
self.p.can_create_replica_without_replication_connection = MagicMock(return_value=True)
|
self.p.can_create_replica_without_replication_connection = MagicMock(return_value=True)
|
||||||
self.assertEquals(self.ha.bootstrap(), 'trying to bootstrap (without leader)')
|
self.assertEqual(self.ha.bootstrap(), 'trying to bootstrap (without leader)')
|
||||||
|
|
||||||
def test_bootstrap_initialize_lock_failed(self):
|
def test_bootstrap_initialize_lock_failed(self):
|
||||||
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||||
self.assertEquals(self.ha.bootstrap(), 'failed to acquire initialize lock')
|
self.assertEqual(self.ha.bootstrap(), 'failed to acquire initialize lock')
|
||||||
|
|
||||||
def test_bootstrap_initialized_new_cluster(self):
|
def test_bootstrap_initialized_new_cluster(self):
|
||||||
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||||
self.e.initialize = true
|
self.e.initialize = true
|
||||||
self.assertEquals(self.ha.bootstrap(), 'trying to bootstrap a new cluster')
|
self.assertEqual(self.ha.bootstrap(), 'trying to bootstrap a new cluster')
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'waiting for end of recovery after bootstrap')
|
self.assertEqual(self.ha.run_cycle(), 'waiting for end of recovery after bootstrap')
|
||||||
self.p.is_leader = true
|
self.p.is_leader = true
|
||||||
self.assertEquals(self.ha.run_cycle(), 'running post_bootstrap')
|
self.assertEqual(self.ha.run_cycle(), 'running post_bootstrap')
|
||||||
self.assertEquals(self.ha.run_cycle(), 'initialized a new cluster')
|
self.assertEqual(self.ha.run_cycle(), 'initialized a new cluster')
|
||||||
|
|
||||||
def test_bootstrap_release_initialize_key_on_failure(self):
|
def test_bootstrap_release_initialize_key_on_failure(self):
|
||||||
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||||
@@ -363,7 +394,7 @@ class TestHa(unittest.TestCase):
|
|||||||
self.p.is_running.return_value = MockPostmaster()
|
self.p.is_running.return_value = MockPostmaster()
|
||||||
self.p.is_leader = true
|
self.p.is_leader = true
|
||||||
with patch.object(Watchdog, 'activate', Mock(return_value=False)):
|
with patch.object(Watchdog, 'activate', Mock(return_value=False)):
|
||||||
self.assertEquals(self.ha.post_bootstrap(), 'running post_bootstrap')
|
self.assertEqual(self.ha.post_bootstrap(), 'running post_bootstrap')
|
||||||
self.assertRaises(PatroniException, self.ha.post_bootstrap)
|
self.assertRaises(PatroniException, self.ha.post_bootstrap)
|
||||||
|
|
||||||
@patch('psycopg2.connect', psycopg2_connect)
|
@patch('psycopg2.connect', psycopg2_connect)
|
||||||
@@ -380,35 +411,35 @@ class TestHa(unittest.TestCase):
|
|||||||
|
|
||||||
@patch('time.sleep', Mock())
|
@patch('time.sleep', Mock())
|
||||||
def test_restart(self):
|
def test_restart(self):
|
||||||
self.assertEquals(self.ha.restart({}), (True, 'restarted successfully'))
|
self.assertEqual(self.ha.restart({}), (True, 'restarted successfully'))
|
||||||
self.p.restart = Mock(return_value=None)
|
self.p.restart = Mock(return_value=None)
|
||||||
self.assertEquals(self.ha.restart({}), (False, 'postgres is still starting'))
|
self.assertEqual(self.ha.restart({}), (False, 'postgres is still starting'))
|
||||||
self.p.restart = false
|
self.p.restart = false
|
||||||
self.assertEquals(self.ha.restart({}), (False, 'restart failed'))
|
self.assertEqual(self.ha.restart({}), (False, 'restart failed'))
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
self.ha.reinitialize()
|
self.ha.reinitialize()
|
||||||
self.assertEquals(self.ha.restart({}), (False, 'reinitialize already in progress'))
|
self.assertEqual(self.ha.restart({}), (False, 'reinitialize already in progress'))
|
||||||
with patch.object(self.ha, "restart_matches", return_value=False):
|
with patch.object(self.ha, "restart_matches", return_value=False):
|
||||||
self.assertEquals(self.ha.restart({'foo': 'bar'}), (False, "restart conditions are not satisfied"))
|
self.assertEqual(self.ha.restart({'foo': 'bar'}), (False, "restart conditions are not satisfied"))
|
||||||
|
|
||||||
@patch('os.kill', Mock())
|
@patch('os.kill', Mock())
|
||||||
def test_restart_in_progress(self):
|
def test_restart_in_progress(self):
|
||||||
with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)):
|
with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)):
|
||||||
self.ha.restart({}, run_async=True)
|
self.ha.restart({}, run_async=True)
|
||||||
self.assertTrue(self.ha.restart_scheduled())
|
self.assertTrue(self.ha.restart_scheduled())
|
||||||
self.assertEquals(self.ha.run_cycle(), 'restart in progress')
|
self.assertEqual(self.ha.run_cycle(), 'restart in progress')
|
||||||
|
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
self.assertEquals(self.ha.run_cycle(), 'restart in progress')
|
self.assertEqual(self.ha.run_cycle(), 'restart in progress')
|
||||||
|
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.assertEquals(self.ha.run_cycle(), 'updated leader lock during restart')
|
self.assertEqual(self.ha.run_cycle(), 'updated leader lock during restart')
|
||||||
|
|
||||||
self.ha.update_lock = false
|
self.ha.update_lock = false
|
||||||
self.p.set_role('master')
|
self.p.set_role('master')
|
||||||
with patch('patroni.async_executor.CriticalTask.cancel', Mock(return_value=False)):
|
with patch('patroni.async_executor.CriticalTask.cancel', Mock(return_value=False)):
|
||||||
with patch('patroni.postgresql.Postgresql.terminate_starting_postmaster') as mock_terminate:
|
with patch('patroni.postgresql.Postgresql.terminate_starting_postmaster') as mock_terminate:
|
||||||
self.assertEquals(self.ha.run_cycle(), 'lost leader lock during restart')
|
self.assertEqual(self.ha.run_cycle(), 'lost leader lock during restart')
|
||||||
mock_terminate.assert_called()
|
mock_terminate.assert_called()
|
||||||
|
|
||||||
@patch('requests.get', requests_get)
|
@patch('requests.get', requests_get)
|
||||||
@@ -416,25 +447,25 @@ class TestHa(unittest.TestCase):
|
|||||||
self.ha.fetch_node_status = get_node_status()
|
self.ha.fetch_node_status = get_node_status()
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', '', None))
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', '', None))
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None))
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None))
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla', None))
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla', None))
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
f = Failover(0, self.p.name, '', None)
|
f = Failover(0, self.p.name, '', None)
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(f)
|
self.ha.cluster = get_cluster_initialized_with_leader(f)
|
||||||
self.assertEquals(self.ha.run_cycle(), 'manual failover: demoting myself')
|
self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself')
|
||||||
self.p.rewind_needed_and_possible = true
|
self.p.rewind_needed_and_possible = true
|
||||||
self.assertEquals(self.ha.run_cycle(), 'manual failover: demoting myself')
|
self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself')
|
||||||
self.ha.fetch_node_status = get_node_status(nofailover=True)
|
self.ha.fetch_node_status = get_node_status(nofailover=True)
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
self.ha.fetch_node_status = get_node_status(watchdog_failed=True)
|
self.ha.fetch_node_status = get_node_status(watchdog_failed=True)
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
self.ha.fetch_node_status = get_node_status(wal_position=1)
|
self.ha.fetch_node_status = get_node_status(wal_position=1)
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
# manual failover from the previous leader to us won't happen if we hold the nofailover flag
|
# manual failover from the previous leader to us won't happen if we hold the nofailover flag
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, None))
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, None))
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
|
|
||||||
# Failover scheduled time must include timezone
|
# Failover scheduled time must include timezone
|
||||||
scheduled = datetime.datetime.now()
|
scheduled = datetime.datetime.now()
|
||||||
@@ -443,19 +474,19 @@ class TestHa(unittest.TestCase):
|
|||||||
|
|
||||||
scheduled = datetime.datetime.utcnow().replace(tzinfo=tzutc)
|
scheduled = datetime.datetime.utcnow().replace(tzinfo=tzutc)
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||||
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
|
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||||
|
|
||||||
scheduled = scheduled + datetime.timedelta(seconds=30)
|
scheduled = scheduled + datetime.timedelta(seconds=30)
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||||
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
|
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||||
|
|
||||||
scheduled = scheduled + datetime.timedelta(seconds=-600)
|
scheduled = scheduled + datetime.timedelta(seconds=-600)
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||||
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
|
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||||
|
|
||||||
scheduled = None
|
scheduled = None
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||||
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
|
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||||
|
|
||||||
@patch('requests.get', requests_get)
|
@patch('requests.get', requests_get)
|
||||||
def test_manual_failover_from_leader_in_pause(self):
|
def test_manual_failover_from_leader_in_pause(self):
|
||||||
@@ -463,9 +494,9 @@ class TestHa(unittest.TestCase):
|
|||||||
self.ha.is_paused = true
|
self.ha.is_paused = true
|
||||||
scheduled = datetime.datetime.now()
|
scheduled = datetime.datetime.now()
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
|
||||||
self.assertEquals('PAUSE: no action. i am the leader with the lock', self.ha.run_cycle())
|
self.assertEqual('PAUSE: no action. i am the leader with the lock', self.ha.run_cycle())
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, '', None))
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, '', None))
|
||||||
self.assertEquals('PAUSE: no action. i am the leader with the lock', self.ha.run_cycle())
|
self.assertEqual('PAUSE: no action. i am the leader with the lock', self.ha.run_cycle())
|
||||||
|
|
||||||
@patch('requests.get', requests_get)
|
@patch('requests.get', requests_get)
|
||||||
def test_manual_failover_from_leader_in_synchronous_mode(self):
|
def test_manual_failover_from_leader_in_synchronous_mode(self):
|
||||||
@@ -475,48 +506,48 @@ class TestHa(unittest.TestCase):
|
|||||||
self.ha.is_failover_possible = false
|
self.ha.is_failover_possible = false
|
||||||
self.ha.process_sync_replication = Mock()
|
self.ha.process_sync_replication = Mock()
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, None))
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, None))
|
||||||
self.assertEquals('no action. i am the leader with the lock', self.ha.run_cycle())
|
self.assertEqual('no action. i am the leader with the lock', self.ha.run_cycle())
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, 'a'))
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, 'a'))
|
||||||
self.ha.is_failover_possible = true
|
self.ha.is_failover_possible = true
|
||||||
self.assertEquals('manual failover: demoting myself', self.ha.run_cycle())
|
self.assertEqual('manual failover: demoting myself', self.ha.run_cycle())
|
||||||
|
|
||||||
@patch('requests.get', requests_get)
|
@patch('requests.get', requests_get)
|
||||||
def test_manual_failover_process_no_leader(self):
|
def test_manual_failover_process_no_leader(self):
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', self.p.name, None))
|
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', self.p.name, None))
|
||||||
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'leader', None))
|
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'leader', None))
|
||||||
self.p.set_role('replica')
|
self.p.set_role('replica')
|
||||||
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||||
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
||||||
self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
||||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, self.p.name, '', None))
|
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, self.p.name, '', None))
|
||||||
self.assertEquals(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
|
||||||
self.ha.fetch_node_status = get_node_status(reachable=False) # inaccessible, in_recovery
|
self.ha.fetch_node_status = get_node_status(reachable=False) # inaccessible, in_recovery
|
||||||
self.p.set_role('replica')
|
self.p.set_role('replica')
|
||||||
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||||
# set failover flag to True for all members of the cluster
|
# set failover flag to True for all members of the cluster
|
||||||
# this should elect the current member, as we are not going to call the API for it.
|
# this should elect the current member, as we are not going to call the API for it.
|
||||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None))
|
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None))
|
||||||
self.ha.fetch_node_status = get_node_status(nofailover=True) # accessible, in_recovery
|
self.ha.fetch_node_status = get_node_status(nofailover=True) # accessible, in_recovery
|
||||||
self.p.set_role('replica')
|
self.p.set_role('replica')
|
||||||
self.assertEquals(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||||
# same as previous, but set the current member to nofailover. In no case it should be elected as a leader
|
# same as previous, but set the current member to nofailover. In no case it should be elected as a leader
|
||||||
self.ha.patroni.nofailover = True
|
self.ha.patroni.nofailover = True
|
||||||
self.assertEquals(self.ha.run_cycle(), 'following a different leader because I am not allowed to promote')
|
self.assertEqual(self.ha.run_cycle(), 'following a different leader because I am not allowed to promote')
|
||||||
|
|
||||||
def test_manual_failover_process_no_leader_in_pause(self):
|
def test_manual_failover_process_no_leader_in_pause(self):
|
||||||
self.ha.is_paused = true
|
self.ha.is_paused = true
|
||||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None))
|
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None))
|
||||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
|
self.assertEqual(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
|
||||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', '', None))
|
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', '', None))
|
||||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
|
self.assertEqual(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
|
||||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', 'blabla', None))
|
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', 'blabla', None))
|
||||||
self.assertEquals('PAUSE: acquired session lock as a leader', self.ha.run_cycle())
|
self.assertEqual('PAUSE: acquired session lock as a leader', self.ha.run_cycle())
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.p.set_role('replica')
|
self.p.set_role('replica')
|
||||||
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', self.p.name, None))
|
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', self.p.name, None))
|
||||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: promoted self to leader by acquiring session lock')
|
self.assertEqual(self.ha.run_cycle(), 'PAUSE: promoted self to leader by acquiring session lock')
|
||||||
|
|
||||||
def test_is_healthiest_node(self):
|
def test_is_healthiest_node(self):
|
||||||
self.ha.state_handler.is_leader = false
|
self.ha.state_handler.is_leader = false
|
||||||
@@ -597,7 +628,7 @@ class TestHa(unittest.TestCase):
|
|||||||
def test_scheduled_restart(self):
|
def test_scheduled_restart(self):
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
with patch.object(self.ha, "evaluate_scheduled_restart", Mock(return_value="restart scheduled")):
|
with patch.object(self.ha, "evaluate_scheduled_restart", Mock(return_value="restart scheduled")):
|
||||||
self.assertEquals(self.ha.run_cycle(), "restart scheduled")
|
self.assertEqual(self.ha.run_cycle(), "restart scheduled")
|
||||||
|
|
||||||
def test_restart_matches(self):
|
def test_restart_matches(self):
|
||||||
self.p._role = 'replica'
|
self.p._role = 'replica'
|
||||||
@@ -614,21 +645,79 @@ class TestHa(unittest.TestCase):
|
|||||||
self.ha.is_paused = true
|
self.ha.is_paused = true
|
||||||
self.p.name = 'leader'
|
self.p.name = 'leader'
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: removed leader lock because postgres is not running as master')
|
self.assertEqual(self.ha.run_cycle(), 'PAUSE: removed leader lock because postgres is not running as master')
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None))
|
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None))
|
||||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: waiting to become master after promote...')
|
self.assertEqual(self.ha.run_cycle(), 'PAUSE: waiting to become master after promote...')
|
||||||
|
|
||||||
|
def test_process_healthy_standby_cluster_as_standby_leader(self):
|
||||||
|
self.p.is_leader = false
|
||||||
|
self.p.name = 'leader'
|
||||||
|
self.ha.cluster = get_standby_cluster_initialized_with_only_leader()
|
||||||
|
msg = 'no action. i am the standby leader with the lock'
|
||||||
|
self.assertEqual(self.ha.run_cycle(), msg)
|
||||||
|
|
||||||
|
def test_process_healthy_standby_cluster_as_cascade_replica(self):
|
||||||
|
self.p.is_leader = false
|
||||||
|
self.p.name = 'replica'
|
||||||
|
self.ha.cluster = get_standby_cluster_initialized_with_only_leader()
|
||||||
|
msg = 'no action. i am a secondary and i am following a leader'
|
||||||
|
self.assertEqual(self.ha.run_cycle(), msg)
|
||||||
|
|
||||||
|
def test_process_unhealthy_standby_cluster_as_standby_leader(self):
|
||||||
|
self.p.is_leader = false
|
||||||
|
self.p.name = 'leader'
|
||||||
|
self.ha.cluster = get_standby_cluster_initialized_with_only_leader()
|
||||||
|
self.ha.cluster.is_unlocked = true
|
||||||
|
self.ha.sysid_valid = true
|
||||||
|
self.p._sysid = True
|
||||||
|
msg = 'promoted self to a standby leader because i had the session lock'
|
||||||
|
self.assertEqual(self.ha.run_cycle(), msg)
|
||||||
|
|
||||||
|
@patch.object(Postgresql, 'rewind_needed_and_possible', Mock(return_value=True))
|
||||||
|
def test_process_unhealthy_standby_cluster_as_cascade_replica(self):
|
||||||
|
self.p.is_leader = false
|
||||||
|
self.p.name = 'replica'
|
||||||
|
self.ha.cluster = get_standby_cluster_initialized_with_only_leader()
|
||||||
|
self.ha.is_unlocked = true
|
||||||
|
self.assertTrue(self.ha.run_cycle().startswith('running pg_rewind from remote_master:'))
|
||||||
|
|
||||||
|
def test_recover_unhealthy_leader_in_standby_cluster(self):
|
||||||
|
self.p.is_leader = false
|
||||||
|
self.p.name = 'leader'
|
||||||
|
self.p.is_running = false
|
||||||
|
self.p.follow = false
|
||||||
|
self.ha.cluster = get_standby_cluster_initialized_with_only_leader()
|
||||||
|
self.assertEqual(self.ha.run_cycle(), 'starting as a standby leader because i had the session lock')
|
||||||
|
|
||||||
|
def test_recover_unhealthy_unlocked_standby_cluster(self):
|
||||||
|
self.p.is_leader = false
|
||||||
|
self.p.name = 'leader'
|
||||||
|
self.p.is_running = false
|
||||||
|
self.p.follow = false
|
||||||
|
self.ha.cluster = get_standby_cluster_initialized_with_only_leader()
|
||||||
|
self.ha.cluster.is_unlocked = true
|
||||||
|
self.ha.has_lock = false
|
||||||
|
self.assertEqual(self.ha.run_cycle(), 'trying to follow a remote master because standby cluster is unhealthy')
|
||||||
|
|
||||||
|
def test_failed_to_update_lock_in_pause(self):
|
||||||
|
self.ha.update_lock = false
|
||||||
|
self.ha.is_paused = true
|
||||||
|
self.p.name = 'leader'
|
||||||
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
|
self.assertEqual(self.ha.run_cycle(),
|
||||||
|
'PAUSE: continue to run as master after failing to update leader lock in DCS')
|
||||||
|
|
||||||
def test_postgres_unhealthy_in_pause(self):
|
def test_postgres_unhealthy_in_pause(self):
|
||||||
self.ha.is_paused = true
|
self.ha.is_paused = true
|
||||||
self.p.is_healthy = false
|
self.p.is_healthy = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: postgres is not running')
|
self.assertEqual(self.ha.run_cycle(), 'PAUSE: postgres is not running')
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: removed leader lock because postgres is not running')
|
self.assertEqual(self.ha.run_cycle(), 'PAUSE: removed leader lock because postgres is not running')
|
||||||
|
|
||||||
def test_no_etcd_connection_in_pause(self):
|
def test_no_etcd_connection_in_pause(self):
|
||||||
self.ha.is_paused = true
|
self.ha.is_paused = true
|
||||||
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
||||||
self.assertEquals(self.ha.run_cycle(), 'PAUSE: DCS is not accessible')
|
self.assertEqual(self.ha.run_cycle(), 'PAUSE: DCS is not accessible')
|
||||||
|
|
||||||
@patch('patroni.ha.Ha.update_lock', return_value=True)
|
@patch('patroni.ha.Ha.update_lock', return_value=True)
|
||||||
@patch('patroni.ha.Ha.demote')
|
@patch('patroni.ha.Ha.demote')
|
||||||
@@ -644,26 +733,26 @@ class TestHa(unittest.TestCase):
|
|||||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
self.p.check_for_startup = true
|
self.p.check_for_startup = true
|
||||||
self.p.time_in_state = lambda: 30
|
self.p.time_in_state = lambda: 30
|
||||||
self.assertEquals(self.ha.run_cycle(), 'PostgreSQL is still starting up, 270 seconds until timeout')
|
self.assertEqual(self.ha.run_cycle(), 'PostgreSQL is still starting up, 270 seconds until timeout')
|
||||||
check_calls([(update_lock, True), (demote, False)])
|
check_calls([(update_lock, True), (demote, False)])
|
||||||
|
|
||||||
self.p.time_in_state = lambda: 350
|
self.p.time_in_state = lambda: 350
|
||||||
self.ha.fetch_node_status = get_node_status(reachable=False) # inaccessible, in_recovery
|
self.ha.fetch_node_status = get_node_status(reachable=False) # inaccessible, in_recovery
|
||||||
self.assertEquals(self.ha.run_cycle(),
|
self.assertEqual(self.ha.run_cycle(),
|
||||||
'master start has timed out, but continuing to wait because failover is not possible')
|
'master start has timed out, but continuing to wait because failover is not possible')
|
||||||
check_calls([(update_lock, True), (demote, False)])
|
check_calls([(update_lock, True), (demote, False)])
|
||||||
|
|
||||||
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
||||||
self.assertEquals(self.ha.run_cycle(), 'stopped PostgreSQL because of startup timeout')
|
self.assertEqual(self.ha.run_cycle(), 'stopped PostgreSQL because of startup timeout')
|
||||||
check_calls([(update_lock, True), (demote, True)])
|
check_calls([(update_lock, True), (demote, True)])
|
||||||
|
|
||||||
update_lock.return_value = False
|
update_lock.return_value = False
|
||||||
self.assertEquals(self.ha.run_cycle(), 'stopped PostgreSQL while starting up because leader key was lost')
|
self.assertEqual(self.ha.run_cycle(), 'stopped PostgreSQL while starting up because leader key was lost')
|
||||||
check_calls([(update_lock, True), (demote, True)])
|
check_calls([(update_lock, True), (demote, True)])
|
||||||
|
|
||||||
self.ha.has_lock = false
|
self.ha.has_lock = false
|
||||||
self.p.is_leader = false
|
self.p.is_leader = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader')
|
||||||
check_calls([(update_lock, False), (demote, False)])
|
check_calls([(update_lock, False), (demote, False)])
|
||||||
|
|
||||||
def test_manual_failover_while_starting(self):
|
def test_manual_failover_while_starting(self):
|
||||||
@@ -672,7 +761,7 @@ class TestHa(unittest.TestCase):
|
|||||||
f = Failover(0, self.p.name, '', None)
|
f = Failover(0, self.p.name, '', None)
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(f)
|
self.ha.cluster = get_cluster_initialized_with_leader(f)
|
||||||
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
||||||
self.assertEquals(self.ha.run_cycle(), 'manual failover: demoting myself')
|
self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself')
|
||||||
|
|
||||||
@patch('patroni.ha.Ha.demote')
|
@patch('patroni.ha.Ha.demote')
|
||||||
def test_failover_immediately_on_zero_master_start_timeout(self, demote):
|
def test_failover_immediately_on_zero_master_start_timeout(self, demote):
|
||||||
@@ -683,7 +772,7 @@ class TestHa(unittest.TestCase):
|
|||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.ha.update_lock = true
|
self.ha.update_lock = true
|
||||||
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
||||||
self.assertEquals(self.ha.run_cycle(), 'stopped PostgreSQL to fail over after a crash')
|
self.assertEqual(self.ha.run_cycle(), 'stopped PostgreSQL to fail over after a crash')
|
||||||
demote.assert_called_once()
|
demote.assert_called_once()
|
||||||
|
|
||||||
@patch('patroni.postgresql.Postgresql.follow')
|
@patch('patroni.postgresql.Postgresql.follow')
|
||||||
@@ -745,18 +834,18 @@ class TestHa(unittest.TestCase):
|
|||||||
self.p.pick_synchronous_standby = Mock(return_value=('other2', True))
|
self.p.pick_synchronous_standby = Mock(return_value=('other2', True))
|
||||||
self.ha.run_cycle()
|
self.ha.run_cycle()
|
||||||
self.ha.dcs.get_cluster.assert_called_once()
|
self.ha.dcs.get_cluster.assert_called_once()
|
||||||
self.assertEquals(self.ha.dcs.write_sync_state.call_count, 2)
|
self.assertEqual(self.ha.dcs.write_sync_state.call_count, 2)
|
||||||
|
|
||||||
# Test updating sync standby key failed due to race
|
# Test updating sync standby key failed due to race
|
||||||
self.ha.dcs.write_sync_state = Mock(side_effect=[True, False])
|
self.ha.dcs.write_sync_state = Mock(side_effect=[True, False])
|
||||||
self.ha.run_cycle()
|
self.ha.run_cycle()
|
||||||
self.assertEquals(self.ha.dcs.write_sync_state.call_count, 2)
|
self.assertEqual(self.ha.dcs.write_sync_state.call_count, 2)
|
||||||
|
|
||||||
# Test changing sync standby failed due to race
|
# Test changing sync standby failed due to race
|
||||||
self.ha.dcs.write_sync_state = Mock(return_value=True)
|
self.ha.dcs.write_sync_state = Mock(return_value=True)
|
||||||
self.ha.dcs.get_cluster = Mock(return_value=get_cluster_initialized_with_leader(sync=('somebodyelse', None)))
|
self.ha.dcs.get_cluster = Mock(return_value=get_cluster_initialized_with_leader(sync=('somebodyelse', None)))
|
||||||
self.ha.run_cycle()
|
self.ha.run_cycle()
|
||||||
self.assertEquals(self.ha.dcs.write_sync_state.call_count, 1)
|
self.assertEqual(self.ha.dcs.write_sync_state.call_count, 1)
|
||||||
|
|
||||||
# Test sync set to '*' when synchronous_mode_strict is enabled
|
# Test sync set to '*' when synchronous_mode_strict is enabled
|
||||||
mock_set_sync.reset_mock()
|
mock_set_sync.reset_mock()
|
||||||
@@ -777,7 +866,7 @@ class TestHa(unittest.TestCase):
|
|||||||
self.ha.cluster = get_cluster_initialized_with_leader(sync=('other', None))
|
self.ha.cluster = get_cluster_initialized_with_leader(sync=('other', None))
|
||||||
|
|
||||||
# When we just became master nobody is sync
|
# When we just became master nobody is sync
|
||||||
self.assertEquals(self.ha.enforce_master_role('msg', 'promote msg'), 'promote msg')
|
self.assertEqual(self.ha.enforce_master_role('msg', 'promote msg'), 'promote msg')
|
||||||
mock_set_sync.assert_called_once_with(None)
|
mock_set_sync.assert_called_once_with(None)
|
||||||
mock_write_sync.assert_called_once_with('leader', None, index=0)
|
mock_write_sync.assert_called_once_with('leader', None, index=0)
|
||||||
|
|
||||||
@@ -805,7 +894,7 @@ class TestHa(unittest.TestCase):
|
|||||||
self.ha.run_cycle()
|
self.ha.run_cycle()
|
||||||
mock_acquire.assert_not_called()
|
mock_acquire.assert_not_called()
|
||||||
mock_follow.assert_called_once()
|
mock_follow.assert_called_once()
|
||||||
self.assertEquals(mock_follow.call_args[0][0], None)
|
self.assertEqual(mock_follow.call_args[0][0], None)
|
||||||
mock_write_sync.assert_not_called()
|
mock_write_sync.assert_not_called()
|
||||||
|
|
||||||
mock_follow.reset_mock()
|
mock_follow.reset_mock()
|
||||||
@@ -856,15 +945,15 @@ class TestHa(unittest.TestCase):
|
|||||||
|
|
||||||
def test_effective_tags(self):
|
def test_effective_tags(self):
|
||||||
self.ha._disable_sync = True
|
self.ha._disable_sync = True
|
||||||
self.assertEquals(self.ha.get_effective_tags(), {'foo': 'bar', 'nosync': True})
|
self.assertEqual(self.ha.get_effective_tags(), {'foo': 'bar', 'nosync': True})
|
||||||
self.ha._disable_sync = False
|
self.ha._disable_sync = False
|
||||||
self.assertEquals(self.ha.get_effective_tags(), {'foo': 'bar'})
|
self.assertEqual(self.ha.get_effective_tags(), {'foo': 'bar'})
|
||||||
|
|
||||||
def test_restore_cluster_config(self):
|
def test_restore_cluster_config(self):
|
||||||
self.ha.cluster.config.data.clear()
|
self.ha.cluster.config.data.clear()
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.ha.cluster.is_unlocked = false
|
self.ha.cluster.is_unlocked = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
|
|
||||||
def test_watch(self):
|
def test_watch(self):
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
@@ -883,16 +972,40 @@ class TestHa(unittest.TestCase):
|
|||||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
self.ha.cluster = get_cluster_initialized_with_leader()
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.p.data_directory_empty = true
|
self.p.data_directory_empty = true
|
||||||
self.assertEquals(self.ha.run_cycle(), 'released leader key voluntarily as data dir empty and currently leader')
|
self.assertEqual(self.ha.run_cycle(), 'released leader key voluntarily as data dir empty and currently leader')
|
||||||
self.assertEquals(self.p.role, 'uninitialized')
|
self.assertEqual(self.p.role, 'uninitialized')
|
||||||
|
|
||||||
# as has_lock is mocked out, we need to fake the leader key release
|
# as has_lock is mocked out, we need to fake the leader key release
|
||||||
self.ha.has_lock = false
|
self.ha.has_lock = false
|
||||||
# will not say bootstrap from leader as replica can't self elect
|
# will not say bootstrap from leader as replica can't self elect
|
||||||
self.assertEquals(self.ha.run_cycle(), "trying to bootstrap from replica 'other'")
|
self.assertEqual(self.ha.run_cycle(), "trying to bootstrap from replica 'other'")
|
||||||
|
|
||||||
def test_update_cluster_history(self):
|
def test_update_cluster_history(self):
|
||||||
self.p.get_master_timeline = Mock(return_value=1)
|
self.p.get_master_timeline = Mock(return_value=1)
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.ha.cluster.is_unlocked = false
|
self.ha.cluster.is_unlocked = false
|
||||||
self.assertEquals(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
|
|
||||||
|
@patch('sys.exit', return_value=1)
|
||||||
|
def test_abort_join(self, exit_mock):
|
||||||
|
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||||
|
self.p.is_leader = false
|
||||||
|
self.ha.run_cycle()
|
||||||
|
exit_mock.assert_called_once_with(1)
|
||||||
|
|
||||||
|
def test_after_pause(self):
|
||||||
|
self.ha.has_lock = true
|
||||||
|
self.ha.cluster.is_unlocked = false
|
||||||
|
self.ha.is_paused = true
|
||||||
|
self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action. i am the leader with the lock')
|
||||||
|
self.ha.is_paused = false
|
||||||
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
|
|
||||||
|
@patch('psycopg2.connect', psycopg2_connect)
|
||||||
|
def test_permanent_logical_slots_after_promote(self):
|
||||||
|
config = ClusterConfig(1, {'slots': {'l': {'database': 'postgres', 'plugin': 'test_decoding'}}}, 1)
|
||||||
|
self.ha.cluster = get_cluster_initialized_without_leader(cluster_config=config)
|
||||||
|
self.assertEqual(self.ha.run_cycle(), 'acquired session lock as a leader')
|
||||||
|
self.ha.cluster = get_cluster_initialized_without_leader(leader=True, cluster_config=config)
|
||||||
|
self.ha.has_lock = true
|
||||||
|
self.assertEqual(self.ha.run_cycle(), 'no action. i am the leader with the lock')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from mock import Mock, patch
|
from mock import Mock, patch
|
||||||
from patroni.dcs.kubernetes import Kubernetes, KubernetesError, k8s_client, k8s_watch
|
from patroni.dcs.kubernetes import Kubernetes, KubernetesError, k8s_client, k8s_watch, RetryFailedError
|
||||||
|
|
||||||
|
|
||||||
def mock_list_namespaced_config_map(self, *args, **kwargs):
|
def mock_list_namespaced_config_map(self, *args, **kwargs):
|
||||||
@@ -50,6 +50,13 @@ class TestKubernetes(unittest.TestCase):
|
|||||||
'labels': {'f': 'b'}, 'use_endpoints': True, 'pod_ip': '10.0.0.0'})
|
'labels': {'f': 'b'}, 'use_endpoints': True, 'pod_ip': '10.0.0.0'})
|
||||||
self.assertIsNotNone(k.update_leader('123'))
|
self.assertIsNotNone(k.update_leader('123'))
|
||||||
|
|
||||||
|
@patch('kubernetes.config.load_kube_config', Mock())
|
||||||
|
@patch.object(k8s_client.CoreV1Api, 'create_namespaced_endpoints', Mock())
|
||||||
|
def test_update_leader_with_restricted_access(self):
|
||||||
|
k = Kubernetes({'ttl': 30, 'scope': 'test', 'name': 'p-0', 'retry_timeout': 10,
|
||||||
|
'labels': {'f': 'b'}, 'use_endpoints': True, 'pod_ip': '10.0.0.0'})
|
||||||
|
self.assertIsNotNone(k.update_leader('123', True))
|
||||||
|
|
||||||
def test_take_leader(self):
|
def test_take_leader(self):
|
||||||
self.k.take_leader()
|
self.k.take_leader()
|
||||||
self.k._leader_observed_record['leader'] = 'test'
|
self.k._leader_observed_record['leader'] = 'test'
|
||||||
@@ -57,7 +64,8 @@ class TestKubernetes(unittest.TestCase):
|
|||||||
self.k.take_leader()
|
self.k.take_leader()
|
||||||
|
|
||||||
def test_manual_failover(self):
|
def test_manual_failover(self):
|
||||||
self.k.manual_failover('foo', 'bar')
|
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):
|
def test_set_config_value(self):
|
||||||
self.k.set_config_value('{}')
|
self.k.set_config_value('{}')
|
||||||
@@ -79,7 +87,7 @@ class TestKubernetes(unittest.TestCase):
|
|||||||
self.k.cancel_initialization()
|
self.k.cancel_initialization()
|
||||||
|
|
||||||
@patch.object(k8s_client.CoreV1Api, 'delete_collection_namespaced_config_map',
|
@patch.object(k8s_client.CoreV1Api, 'delete_collection_namespaced_config_map',
|
||||||
Mock(side_effect=k8s_client.rest.ApiException(500, '')))
|
Mock(side_effect=k8s_client.rest.ApiException(403, '')))
|
||||||
def test_delete_cluster(self):
|
def test_delete_cluster(self):
|
||||||
self.k.delete_cluster()
|
self.k.delete_cluster()
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class TestPatroni(unittest.TestCase):
|
|||||||
mock_getpid.return_value = 2
|
mock_getpid.return_value = 2
|
||||||
_main()
|
_main()
|
||||||
|
|
||||||
with patch('sys.frozen', Mock(return_value=True), create=True):
|
with patch('sys.frozen', Mock(return_value=True), create=True), patch('os.setsid', Mock()):
|
||||||
sys.argv = ['/patroni', 'pg_ctl_start', 'postgres', '-D', '/data', '--max_connections=100']
|
sys.argv = ['/patroni', 'pg_ctl_start', 'postgres', '-D', '/data', '--max_connections=100']
|
||||||
_main()
|
_main()
|
||||||
|
|
||||||
@@ -103,6 +103,7 @@ class TestPatroni(unittest.TestCase):
|
|||||||
|
|
||||||
@patch('patroni.config.Config.save_cache', Mock())
|
@patch('patroni.config.Config.save_cache', Mock())
|
||||||
@patch('patroni.config.Config.reload_local_configuration', Mock(return_value=True))
|
@patch('patroni.config.Config.reload_local_configuration', Mock(return_value=True))
|
||||||
|
@patch('patroni.ha.Ha.is_leader', Mock(return_value=True))
|
||||||
@patch.object(Postgresql, 'state', PropertyMock(return_value='running'))
|
@patch.object(Postgresql, 'state', PropertyMock(return_value='running'))
|
||||||
@patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False))
|
@patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False))
|
||||||
def test_run(self):
|
def test_run(self):
|
||||||
@@ -151,3 +152,7 @@ class TestPatroni(unittest.TestCase):
|
|||||||
self.assertTrue(self.p.nosync)
|
self.assertTrue(self.p.nosync)
|
||||||
self.p.tags['nosync'] = None
|
self.p.tags['nosync'] = None
|
||||||
self.assertFalse(self.p.nosync)
|
self.assertFalse(self.p.nosync)
|
||||||
|
|
||||||
|
def test_shutdown(self):
|
||||||
|
self.p.api.shutdown = Mock(side_effect=Exception)
|
||||||
|
self.p.shutdown()
|
||||||
|
|||||||
+156
-90
@@ -8,13 +8,13 @@ import unittest
|
|||||||
|
|
||||||
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
|
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
|
||||||
from patroni.async_executor import CriticalTask
|
from patroni.async_executor import CriticalTask
|
||||||
from patroni.dcs import Cluster, Leader, Member, SyncState
|
from patroni.dcs import Cluster, ClusterConfig, Leader, Member, RemoteMember, SyncState
|
||||||
from patroni.exceptions import PostgresConnectionException, PostgresException
|
from patroni.exceptions import PostgresConnectionException, PostgresException
|
||||||
from patroni.postgresql import Postgresql, STATE_REJECT, STATE_NO_RESPONSE
|
from patroni.postgresql import Postgresql, STATE_REJECT, STATE_NO_RESPONSE
|
||||||
from patroni.postmaster import PostmasterProcess
|
from patroni.postmaster import PostmasterProcess
|
||||||
from patroni.utils import RetryFailedError
|
from patroni.utils import RetryFailedError
|
||||||
from six.moves import builtins
|
from six.moves import builtins
|
||||||
from threading import Thread
|
from threading import Thread, current_thread
|
||||||
|
|
||||||
|
|
||||||
class MockCursor(object):
|
class MockCursor(object):
|
||||||
@@ -28,15 +28,15 @@ class MockCursor(object):
|
|||||||
def execute(self, sql, *params):
|
def execute(self, sql, *params):
|
||||||
if sql.startswith('blabla'):
|
if sql.startswith('blabla'):
|
||||||
raise psycopg2.ProgrammingError()
|
raise psycopg2.ProgrammingError()
|
||||||
elif sql == 'CHECKPOINT':
|
elif sql == 'CHECKPOINT' or sql.startswith('SELECT pg_catalog.pg_create_'):
|
||||||
raise psycopg2.OperationalError()
|
raise psycopg2.OperationalError()
|
||||||
elif sql.startswith('RetryFailedError'):
|
elif sql.startswith('RetryFailedError'):
|
||||||
raise RetryFailedError('retry')
|
raise RetryFailedError('retry')
|
||||||
elif sql.startswith('SELECT slot_name'):
|
elif sql.startswith('SELECT slot_name'):
|
||||||
self.results = [('blabla',), ('foobar',)]
|
self.results = [('blabla', 'physical'), ('foobar', 'physical'), ('ls', 'logical', 'a', 'b')]
|
||||||
elif sql.startswith('SELECT CASE WHEN pg_is_in_recovery()'):
|
elif sql.startswith('SELECT CASE WHEN pg_catalog.pg_is_in_recovery()'):
|
||||||
self.results = [(1, 2)]
|
self.results = [(1, 2)]
|
||||||
elif sql.startswith('SELECT pg_is_in_recovery()'):
|
elif sql.startswith('SELECT pg_catalog.pg_is_in_recovery()'):
|
||||||
self.results = [(False, 2)]
|
self.results = [(False, 2)]
|
||||||
elif sql.startswith('WITH replication_info AS ('):
|
elif sql.startswith('WITH replication_info AS ('):
|
||||||
replication_info = '[{"application_name":"walreceiver","client_addr":"1.2.3.4",' +\
|
replication_info = '[{"application_name":"walreceiver","client_addr":"1.2.3.4",' +\
|
||||||
@@ -53,7 +53,7 @@ class MockCursor(object):
|
|||||||
self.results = [('1', 2, '0/402EEC0', '')]
|
self.results = [('1', 2, '0/402EEC0', '')]
|
||||||
elif sql.startswith('SELECT isdir, modification'):
|
elif sql.startswith('SELECT isdir, modification'):
|
||||||
self.results = [(False, datetime.datetime.now())]
|
self.results = [(False, datetime.datetime.now())]
|
||||||
elif sql.startswith('SELECT pg_read_file'):
|
elif sql.startswith('SELECT pg_catalog.pg_read_file'):
|
||||||
self.results = [('1\t0/40159C0\tno recovery target specified\n\n' +
|
self.results = [('1\t0/40159C0\tno recovery target specified\n\n' +
|
||||||
'2\t1/40159C0\tno recovery target specified\n',)]
|
'2\t1/40159C0\tno recovery target specified\n',)]
|
||||||
elif sql.startswith('TIMELINE_HISTORY '):
|
elif sql.startswith('TIMELINE_HISTORY '):
|
||||||
@@ -210,12 +210,11 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
shutil.rmtree('data')
|
shutil.rmtree('data')
|
||||||
|
|
||||||
def test_get_initdb_options(self):
|
def test__initdb(self):
|
||||||
self.assertEquals(self.p.get_initdb_options([{'encoding': 'UTF8'}, 'data-checksums']),
|
self.assertRaises(Exception, self.p.bootstrap, {'initdb': [{'pgdata': 'bar'}]})
|
||||||
['--encoding=UTF8', '--data-checksums'])
|
self.assertRaises(Exception, self.p.bootstrap, {'initdb': [{'foo': 'bar', 1: 2}]})
|
||||||
self.assertRaises(Exception, self.p.get_initdb_options, [{'pgdata': 'bar'}])
|
self.assertRaises(Exception, self.p.bootstrap, {'initdb': [1]})
|
||||||
self.assertRaises(Exception, self.p.get_initdb_options, [{'foo': 'bar', 1: 2}])
|
self.assertRaises(Exception, self.p.bootstrap, {'initdb': 1})
|
||||||
self.assertRaises(Exception, self.p.get_initdb_options, [1])
|
|
||||||
|
|
||||||
@patch('os.path.exists', Mock(return_value=True))
|
@patch('os.path.exists', Mock(return_value=True))
|
||||||
@patch('os.unlink', Mock())
|
@patch('os.unlink', Mock())
|
||||||
@@ -309,7 +308,7 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
def test_restart(self):
|
def test_restart(self):
|
||||||
self.p.start = Mock(return_value=False)
|
self.p.start = Mock(return_value=False)
|
||||||
self.assertFalse(self.p.restart())
|
self.assertFalse(self.p.restart())
|
||||||
self.assertEquals(self.p.state, 'restart failed (restarting)')
|
self.assertEqual(self.p.state, 'restart failed (restarting)')
|
||||||
|
|
||||||
@patch.object(builtins, 'open', MagicMock())
|
@patch.object(builtins, 'open', MagicMock())
|
||||||
def test_write_pgpass(self):
|
def test_write_pgpass(self):
|
||||||
@@ -318,10 +317,10 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
|
|
||||||
def test_checkpoint(self):
|
def test_checkpoint(self):
|
||||||
with patch.object(MockCursor, 'fetchone', Mock(return_value=(True, ))):
|
with patch.object(MockCursor, 'fetchone', Mock(return_value=(True, ))):
|
||||||
self.assertEquals(self.p.checkpoint({'user': 'postgres'}), 'is_in_recovery=true')
|
self.assertEqual(self.p.checkpoint({'user': 'postgres'}), 'is_in_recovery=true')
|
||||||
with patch.object(MockCursor, 'execute', Mock(return_value=None)):
|
with patch.object(MockCursor, 'execute', Mock(return_value=None)):
|
||||||
self.assertIsNone(self.p.checkpoint())
|
self.assertIsNone(self.p.checkpoint())
|
||||||
self.assertEquals(self.p.checkpoint(), 'not accessible or not healty')
|
self.assertEqual(self.p.checkpoint(), 'not accessible or not healty')
|
||||||
|
|
||||||
@patch.object(Postgresql, 'cancellable_subprocess_call')
|
@patch.object(Postgresql, 'cancellable_subprocess_call')
|
||||||
@patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
|
@patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict()))
|
||||||
@@ -402,7 +401,8 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
@patch.object(Postgresql, 'is_running', Mock(return_value=False))
|
@patch.object(Postgresql, 'is_running', Mock(return_value=False))
|
||||||
@patch.object(Postgresql, 'start', Mock())
|
@patch.object(Postgresql, 'start', Mock())
|
||||||
def test_follow(self):
|
def test_follow(self):
|
||||||
self.p.follow(None)
|
m = RemoteMember('1', {'restore_command': '2', 'recovery_min_apply_delay': 3, 'archive_cleanup_command': '4'})
|
||||||
|
self.p.follow(m)
|
||||||
|
|
||||||
@patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string))
|
@patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string))
|
||||||
def test_can_rewind(self):
|
def test_can_rewind(self):
|
||||||
@@ -421,50 +421,106 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
def test_create_replica(self, mock_cancellable_subprocess_call):
|
def test_create_replica(self, mock_cancellable_subprocess_call):
|
||||||
self.p.delete_trigger_file = Mock(side_effect=OSError)
|
self.p.delete_trigger_file = Mock(side_effect=OSError)
|
||||||
|
|
||||||
|
self.p.config['create_replica_methods'] = ['pgBackRest']
|
||||||
|
self.p.config['pgBackRest'] = {'command': 'pgBackRest', 'keep_data': True, 'no_params': True}
|
||||||
|
mock_cancellable_subprocess_call.return_value = 0
|
||||||
|
self.assertEqual(self.p.create_replica(self.leader), 0)
|
||||||
|
|
||||||
|
self.p.config['create_replica_methods'] = ['wale', 'basebackup']
|
||||||
|
self.p.config['wale'] = {'command': 'foo'}
|
||||||
|
self.assertEqual(self.p.create_replica(self.leader), 0)
|
||||||
|
del self.p.config['wale']
|
||||||
|
self.assertEqual(self.p.create_replica(self.leader), 0)
|
||||||
|
|
||||||
|
self.p.config['create_replica_methods'] = ['basebackup']
|
||||||
|
self.p.config['basebackup'] = [{'max_rate': '100M'}, 'no-sync']
|
||||||
|
self.assertEqual(self.p.create_replica(self.leader), 0)
|
||||||
|
|
||||||
|
self.p.config['basebackup'] = [{'max_rate': '100M', 'compress': '9'}]
|
||||||
|
with mock.patch('patroni.postgresql.logger.error', new_callable=Mock()) as mock_logger:
|
||||||
|
self.p.create_replica(self.leader)
|
||||||
|
mock_logger.assert_called_once()
|
||||||
|
self.assertTrue("only one key-value is allowed and value should be a string" in mock_logger.call_args[0][0],
|
||||||
|
"not matching {0}".format(mock_logger.call_args[0][0]))
|
||||||
|
|
||||||
|
self.p.config['basebackup'] = [42]
|
||||||
|
with mock.patch('patroni.postgresql.logger.error', new_callable=Mock()) as mock_logger:
|
||||||
|
self.p.create_replica(self.leader)
|
||||||
|
mock_logger.assert_called_once()
|
||||||
|
self.assertTrue("value should be string value or a single key-value pair" in mock_logger.call_args[0][0],
|
||||||
|
"not matching {0}".format(mock_logger.call_args[0][0]))
|
||||||
|
|
||||||
|
self.p.config['basebackup'] = {"foo": "bar"}
|
||||||
|
self.assertEqual(self.p.create_replica(self.leader), 0)
|
||||||
|
|
||||||
|
self.p.config['create_replica_methods'] = ['wale', 'basebackup']
|
||||||
|
del self.p.config['basebackup']
|
||||||
|
mock_cancellable_subprocess_call.return_value = 1
|
||||||
|
self.assertEqual(self.p.create_replica(self.leader), 1)
|
||||||
|
|
||||||
|
mock_cancellable_subprocess_call.side_effect = Exception('foo')
|
||||||
|
self.assertEqual(self.p.create_replica(self.leader), 1)
|
||||||
|
|
||||||
|
mock_cancellable_subprocess_call.side_effect = [1, 0]
|
||||||
|
self.assertEqual(self.p.create_replica(self.leader), 0)
|
||||||
|
|
||||||
|
mock_cancellable_subprocess_call.side_effect = [Exception(), 0]
|
||||||
|
self.assertEqual(self.p.create_replica(self.leader), 0)
|
||||||
|
|
||||||
|
self.p.cancel()
|
||||||
|
self.assertEqual(self.p.create_replica(self.leader), 1)
|
||||||
|
|
||||||
|
@patch('time.sleep', Mock())
|
||||||
|
@patch.object(Postgresql, 'cancellable_subprocess_call')
|
||||||
|
@patch.object(Postgresql, 'remove_data_directory', Mock(return_value=True))
|
||||||
|
def test_create_replica_old_format(self, mock_cancellable_subprocess_call):
|
||||||
|
""" The same test as before but with old 'create_replica_method'
|
||||||
|
to test backward compatibility
|
||||||
|
"""
|
||||||
|
self.p.delete_trigger_file = Mock(side_effect=OSError)
|
||||||
|
|
||||||
self.p.config['create_replica_method'] = ['wale', 'basebackup']
|
self.p.config['create_replica_method'] = ['wale', 'basebackup']
|
||||||
self.p.config['wale'] = {'command': 'foo'}
|
self.p.config['wale'] = {'command': 'foo'}
|
||||||
mock_cancellable_subprocess_call.return_value = 0
|
mock_cancellable_subprocess_call.return_value = 0
|
||||||
self.assertEquals(self.p.create_replica(self.leader), 0)
|
self.assertEqual(self.p.create_replica(self.leader), 0)
|
||||||
del self.p.config['wale']
|
del self.p.config['wale']
|
||||||
self.assertEquals(self.p.create_replica(self.leader), 0)
|
self.assertEqual(self.p.create_replica(self.leader), 0)
|
||||||
|
|
||||||
|
self.p.config['create_replica_method'] = ['basebackup']
|
||||||
|
self.p.config['basebackup'] = [{'max_rate': '100M'}, 'no-sync']
|
||||||
|
self.assertEqual(self.p.create_replica(self.leader), 0)
|
||||||
|
|
||||||
|
self.p.config['create_replica_method'] = ['wale', 'basebackup']
|
||||||
|
del self.p.config['basebackup']
|
||||||
mock_cancellable_subprocess_call.return_value = 1
|
mock_cancellable_subprocess_call.return_value = 1
|
||||||
self.assertEquals(self.p.create_replica(self.leader), 1)
|
self.assertEqual(self.p.create_replica(self.leader), 1)
|
||||||
|
|
||||||
mock_cancellable_subprocess_call.side_effect = Exception('foo')
|
|
||||||
self.assertEquals(self.p.create_replica(self.leader), 1)
|
|
||||||
|
|
||||||
mock_cancellable_subprocess_call.side_effect = [1, 0]
|
|
||||||
self.assertEquals(self.p.create_replica(self.leader), 0)
|
|
||||||
|
|
||||||
mock_cancellable_subprocess_call.side_effect = [Exception(), 0]
|
|
||||||
self.assertEquals(self.p.create_replica(self.leader), 0)
|
|
||||||
|
|
||||||
self.p.cancel()
|
|
||||||
self.assertEquals(self.p.create_replica(self.leader), 1)
|
|
||||||
|
|
||||||
def test_basebackup(self):
|
def test_basebackup(self):
|
||||||
self.p.cancel()
|
self.p.cancel()
|
||||||
self.p.basebackup(None, None)
|
self.p.basebackup(None, None, {'foo': 'bar'})
|
||||||
|
|
||||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||||
def test_sync_replication_slots(self):
|
def test_sync_replication_slots(self):
|
||||||
self.p.start()
|
self.p.start()
|
||||||
cluster = Cluster(True, None, self.leader, 0, [self.me, self.other, self.leadermem], None, None, None)
|
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'},
|
||||||
|
'A': 0, 'test_3': 0, 'b': {'type': 'logical', 'plugin': '1'}}}, 1)
|
||||||
|
cluster = Cluster(True, config, self.leader, 0, [self.me, self.other, self.leadermem], None, None, None)
|
||||||
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg2.OperationalError)):
|
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg2.OperationalError)):
|
||||||
self.p.sync_replication_slots(cluster)
|
self.p.sync_replication_slots(cluster)
|
||||||
self.p.sync_replication_slots(cluster)
|
self.p.sync_replication_slots(cluster)
|
||||||
with mock.patch('patroni.postgresql.Postgresql.role', new_callable=PropertyMock(return_value='replica')):
|
with mock.patch('patroni.postgresql.Postgresql.role', new_callable=PropertyMock(return_value='replica')):
|
||||||
self.p.sync_replication_slots(cluster)
|
self.p.sync_replication_slots(cluster)
|
||||||
with mock.patch('patroni.postgresql.logger.error', new_callable=Mock()) as errorlog_mock:
|
with patch.object(Postgresql, 'drop_replication_slot', Mock(return_value=True)),\
|
||||||
|
patch('patroni.dcs.logger.error', new_callable=Mock()) as errorlog_mock:
|
||||||
self.p.query = Mock()
|
self.p.query = Mock()
|
||||||
alias1 = Member(0, 'test-3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
alias1 = Member(0, 'test-3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
||||||
alias2 = Member(0, 'test.3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
alias2 = Member(0, 'test.3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
||||||
cluster.members.extend([alias1, alias2])
|
cluster.members.extend([alias1, alias2])
|
||||||
self.p.sync_replication_slots(cluster)
|
self.p.sync_replication_slots(cluster)
|
||||||
errorlog_mock.assert_called_once()
|
self.assertEqual(errorlog_mock.call_count, 5)
|
||||||
assert "test-3" in errorlog_mock.call_args[0][1]
|
ca = errorlog_mock.call_args_list[0][0][1]
|
||||||
assert "test.3" in errorlog_mock.call_args[0][1]
|
self.assertTrue("test-3" in ca, "non matching {0}".format(ca))
|
||||||
|
self.assertTrue("test.3" in ca, "non matching {0}".format(ca))
|
||||||
|
|
||||||
@patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError))
|
@patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError))
|
||||||
def test__query(self):
|
def test__query(self):
|
||||||
@@ -500,25 +556,25 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
self.assertTrue(self.p.promote(0))
|
self.assertTrue(self.p.promote(0))
|
||||||
|
|
||||||
def test_timeline_wal_position(self):
|
def test_timeline_wal_position(self):
|
||||||
self.assertEquals(self.p.timeline_wal_position(), (1, 2))
|
self.assertEqual(self.p.timeline_wal_position(), (1, 2))
|
||||||
Thread(target=self.p.timeline_wal_position).start()
|
Thread(target=self.p.timeline_wal_position).start()
|
||||||
|
|
||||||
@patch.object(PostmasterProcess, 'from_pidfile')
|
@patch.object(PostmasterProcess, 'from_pidfile')
|
||||||
def test_is_running(self, mock_frompidfile):
|
def test_is_running(self, mock_frompidfile):
|
||||||
# Cached postmaster running
|
# Cached postmaster running
|
||||||
mock_postmaster = self.p._postmaster_proc = MockPostmaster()
|
mock_postmaster = self.p._postmaster_proc = MockPostmaster()
|
||||||
self.assertEquals(self.p.is_running(), mock_postmaster)
|
self.assertEqual(self.p.is_running(), mock_postmaster)
|
||||||
|
|
||||||
# Cached postmaster not running, no postmaster running
|
# Cached postmaster not running, no postmaster running
|
||||||
mock_postmaster.is_running.return_value = False
|
mock_postmaster.is_running.return_value = False
|
||||||
mock_frompidfile.return_value = None
|
mock_frompidfile.return_value = None
|
||||||
self.assertEquals(self.p.is_running(), None)
|
self.assertEqual(self.p.is_running(), None)
|
||||||
self.assertEquals(self.p._postmaster_proc, None)
|
self.assertEqual(self.p._postmaster_proc, None)
|
||||||
|
|
||||||
# No cached postmaster, postmaster running
|
# No cached postmaster, postmaster running
|
||||||
mock_frompidfile.return_value = mock_postmaster2 = MockPostmaster()
|
mock_frompidfile.return_value = mock_postmaster2 = MockPostmaster()
|
||||||
self.assertEquals(self.p.is_running(), mock_postmaster2)
|
self.assertEqual(self.p.is_running(), mock_postmaster2)
|
||||||
self.assertEquals(self.p._postmaster_proc, mock_postmaster2)
|
self.assertEqual(self.p._postmaster_proc, mock_postmaster2)
|
||||||
|
|
||||||
@patch('shlex.split', Mock(side_effect=OSError))
|
@patch('shlex.split', Mock(side_effect=OSError))
|
||||||
def test_call_nowait(self):
|
def test_call_nowait(self):
|
||||||
@@ -567,6 +623,7 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
|
|
||||||
@patch.object(Postgresql, 'cancellable_subprocess_call')
|
@patch.object(Postgresql, 'cancellable_subprocess_call')
|
||||||
def test_custom_bootstrap(self, mock_cancellable_subprocess_call):
|
def test_custom_bootstrap(self, mock_cancellable_subprocess_call):
|
||||||
|
self.p.config.pop('pg_hba')
|
||||||
config = {'method': 'foo', 'foo': {'command': 'bar'}}
|
config = {'method': 'foo', 'foo': {'command': 'bar'}}
|
||||||
|
|
||||||
mock_cancellable_subprocess_call.return_value = 1
|
mock_cancellable_subprocess_call.return_value = 1
|
||||||
@@ -610,6 +667,12 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
self.p.post_bootstrap({}, task)
|
self.p.post_bootstrap({}, task)
|
||||||
self.assertTrue(task.result)
|
self.assertTrue(task.result)
|
||||||
|
|
||||||
|
self.p.bootstrap(config)
|
||||||
|
with patch.object(Postgresql, 'pending_restart', PropertyMock(return_value=True)), \
|
||||||
|
patch.object(Postgresql, 'restart', Mock()) as mock_restart:
|
||||||
|
self.p.post_bootstrap({}, task)
|
||||||
|
mock_restart.assert_called_once()
|
||||||
|
|
||||||
self.p.bootstrap(config)
|
self.p.bootstrap(config)
|
||||||
self.p.set_state('stopped')
|
self.p.set_state('stopped')
|
||||||
self.p.reload_config({'authentication': {'superuser': {'username': 'p', 'password': 'p'},
|
self.p.reload_config({'authentication': {'superuser': {'username': 'p', 'password': 'p'},
|
||||||
@@ -630,13 +693,13 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
mock_cancellable_subprocess_call.assert_called()
|
mock_cancellable_subprocess_call.assert_called()
|
||||||
args, kwargs = mock_cancellable_subprocess_call.call_args
|
args, kwargs = mock_cancellable_subprocess_call.call_args
|
||||||
self.assertTrue('PGPASSFILE' in kwargs['env'])
|
self.assertTrue('PGPASSFILE' in kwargs['env'])
|
||||||
self.assertEquals(args[0], ['/bin/false', 'postgres://127.0.0.2:5432/postgres'])
|
self.assertEqual(args[0], ['/bin/false', 'postgres://127.0.0.2:5432/postgres'])
|
||||||
|
|
||||||
mock_cancellable_subprocess_call.reset_mock()
|
mock_cancellable_subprocess_call.reset_mock()
|
||||||
self.p._local_address.pop('host')
|
self.p._local_address.pop('host')
|
||||||
self.assertTrue(self.p.run_bootstrap_post_init({'post_init': '/bin/false'}))
|
self.assertTrue(self.p.run_bootstrap_post_init({'post_init': '/bin/false'}))
|
||||||
mock_cancellable_subprocess_call.assert_called()
|
mock_cancellable_subprocess_call.assert_called()
|
||||||
self.assertEquals(mock_cancellable_subprocess_call.call_args[0][0], ['/bin/false', 'postgres://:5432/postgres'])
|
self.assertEqual(mock_cancellable_subprocess_call.call_args[0][0], ['/bin/false', 'postgres://:5432/postgres'])
|
||||||
|
|
||||||
mock_cancellable_subprocess_call.side_effect = OSError
|
mock_cancellable_subprocess_call.side_effect = OSError
|
||||||
self.assertFalse(self.p.run_bootstrap_post_init({'post_init': '/bin/false'}))
|
self.assertFalse(self.p.run_bootstrap_post_init({'post_init': '/bin/false'}))
|
||||||
@@ -648,7 +711,7 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
@patch('os.listdir', Mock(return_value=['recovery.conf']))
|
@patch('os.listdir', Mock(return_value=['recovery.conf']))
|
||||||
@patch('os.path.exists', Mock(return_value=True))
|
@patch('os.path.exists', Mock(return_value=True))
|
||||||
def test_get_postgres_role_from_data_directory(self):
|
def test_get_postgres_role_from_data_directory(self):
|
||||||
self.assertEquals(self.p.get_postgres_role_from_data_directory(), 'replica')
|
self.assertEqual(self.p.get_postgres_role_from_data_directory(), 'replica')
|
||||||
|
|
||||||
def test_remove_data_directory(self):
|
def test_remove_data_directory(self):
|
||||||
self.p.remove_data_directory()
|
self.p.remove_data_directory()
|
||||||
@@ -663,13 +726,13 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
def test_controldata(self):
|
def test_controldata(self):
|
||||||
with patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string)):
|
with patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string)):
|
||||||
data = self.p.controldata()
|
data = self.p.controldata()
|
||||||
self.assertEquals(len(data), 50)
|
self.assertEqual(len(data), 50)
|
||||||
self.assertEquals(data['Database cluster state'], 'shut down in recovery')
|
self.assertEqual(data['Database cluster state'], 'shut down in recovery')
|
||||||
self.assertEquals(data['wal_log_hints setting'], 'on')
|
self.assertEqual(data['wal_log_hints setting'], 'on')
|
||||||
self.assertEquals(int(data['Database block size']), 8192)
|
self.assertEqual(int(data['Database block size']), 8192)
|
||||||
|
|
||||||
with patch('subprocess.check_output', Mock(side_effect=subprocess.CalledProcessError(1, ''))):
|
with patch('subprocess.check_output', Mock(side_effect=subprocess.CalledProcessError(1, ''))):
|
||||||
self.assertEquals(self.p.controldata(), {})
|
self.assertEqual(self.p.controldata(), {})
|
||||||
|
|
||||||
@patch('patroni.postgresql.Postgresql._version_file_exists', Mock(return_value=True))
|
@patch('patroni.postgresql.Postgresql._version_file_exists', Mock(return_value=True))
|
||||||
@patch('subprocess.check_output', MagicMock(return_value=0, side_effect=pg_controldata_string))
|
@patch('subprocess.check_output', MagicMock(return_value=0, side_effect=pg_controldata_string))
|
||||||
@@ -723,9 +786,9 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
@patch.object(Postgresql, '_version_file_exists', Mock(return_value=True))
|
@patch.object(Postgresql, '_version_file_exists', Mock(return_value=True))
|
||||||
def test_get_major_version(self):
|
def test_get_major_version(self):
|
||||||
with patch.object(builtins, 'open', mock_open(read_data='9.4')):
|
with patch.object(builtins, 'open', mock_open(read_data='9.4')):
|
||||||
self.assertEquals(self.p.get_major_version(), 90400)
|
self.assertEqual(self.p.get_major_version(), 90400)
|
||||||
with patch.object(builtins, 'open', Mock(side_effect=Exception)):
|
with patch.object(builtins, 'open', Mock(side_effect=Exception)):
|
||||||
self.assertEquals(self.p.get_major_version(), 0)
|
self.assertEqual(self.p.get_major_version(), 0)
|
||||||
|
|
||||||
def test_postmaster_start_time(self):
|
def test_postmaster_start_time(self):
|
||||||
with patch.object(MockCursor, "fetchone", Mock(return_value=('foo', True, '', '', '', '', False))):
|
with patch.object(MockCursor, "fetchone", Mock(return_value=('foo', True, '', '', '', '', False))):
|
||||||
@@ -737,38 +800,40 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
with patch('subprocess.call', return_value=0):
|
with patch('subprocess.call', return_value=0):
|
||||||
self.p._state = 'starting'
|
self.p._state = 'starting'
|
||||||
self.assertFalse(self.p.check_for_startup())
|
self.assertFalse(self.p.check_for_startup())
|
||||||
self.assertEquals(self.p.state, 'running')
|
self.assertEqual(self.p.state, 'running')
|
||||||
|
|
||||||
with patch('subprocess.call', return_value=1):
|
with patch('subprocess.call', return_value=1):
|
||||||
self.p._state = 'starting'
|
self.p._state = 'starting'
|
||||||
self.assertTrue(self.p.check_for_startup())
|
self.assertTrue(self.p.check_for_startup())
|
||||||
self.assertEquals(self.p.state, 'starting')
|
self.assertEqual(self.p.state, 'starting')
|
||||||
|
|
||||||
with patch('subprocess.call', return_value=2):
|
with patch('subprocess.call', return_value=2):
|
||||||
self.p._state = 'starting'
|
self.p._state = 'starting'
|
||||||
self.assertFalse(self.p.check_for_startup())
|
self.assertFalse(self.p.check_for_startup())
|
||||||
self.assertEquals(self.p.state, 'start failed')
|
self.assertEqual(self.p.state, 'start failed')
|
||||||
|
|
||||||
with patch('subprocess.call', return_value=0):
|
with patch('subprocess.call', return_value=0):
|
||||||
self.p._state = 'running'
|
self.p._state = 'running'
|
||||||
self.assertFalse(self.p.check_for_startup())
|
self.assertFalse(self.p.check_for_startup())
|
||||||
self.assertEquals(self.p.state, 'running')
|
self.assertEqual(self.p.state, 'running')
|
||||||
|
|
||||||
with patch('subprocess.call', return_value=127):
|
with patch('subprocess.call', return_value=127):
|
||||||
self.p._state = 'running'
|
self.p._state = 'running'
|
||||||
self.assertFalse(self.p.check_for_startup())
|
self.assertFalse(self.p.check_for_startup())
|
||||||
self.assertEquals(self.p.state, 'running')
|
self.assertEqual(self.p.state, 'running')
|
||||||
|
|
||||||
self.p._state = 'starting'
|
self.p._state = 'starting'
|
||||||
self.assertFalse(self.p.check_for_startup())
|
self.assertFalse(self.p.check_for_startup())
|
||||||
self.assertEquals(self.p.state, 'running')
|
self.assertEqual(self.p.state, 'running')
|
||||||
|
|
||||||
def test_wait_for_startup(self):
|
def test_wait_for_startup(self):
|
||||||
state = {'sleeps': 0, 'num_rejects': 0, 'final_return': 0}
|
state = {'sleeps': 0, 'num_rejects': 0, 'final_return': 0}
|
||||||
|
self.__thread_ident = current_thread().ident
|
||||||
|
|
||||||
def increment_sleeps(*args):
|
def increment_sleeps(*args):
|
||||||
print("Sleep")
|
if current_thread().ident == self.__thread_ident:
|
||||||
state['sleeps'] += 1
|
print("Sleep")
|
||||||
|
state['sleeps'] += 1
|
||||||
|
|
||||||
def isready_return(*args):
|
def isready_return(*args):
|
||||||
ret = 1 if state['sleeps'] < state['num_rejects'] else state['final_return']
|
ret = 1 if state['sleeps'] < state['num_rejects'] else state['final_return']
|
||||||
@@ -784,12 +849,12 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
|
|
||||||
self.p._state = 'stopped'
|
self.p._state = 'stopped'
|
||||||
self.assertTrue(self.p.wait_for_startup())
|
self.assertTrue(self.p.wait_for_startup())
|
||||||
self.assertEquals(state['sleeps'], 0)
|
self.assertEqual(state['sleeps'], 0)
|
||||||
|
|
||||||
self.p._state = 'starting'
|
self.p._state = 'starting'
|
||||||
state['num_rejects'] = 5
|
state['num_rejects'] = 5
|
||||||
self.assertTrue(self.p.wait_for_startup())
|
self.assertTrue(self.p.wait_for_startup())
|
||||||
self.assertEquals(state['sleeps'], 5)
|
self.assertEqual(state['sleeps'], 5)
|
||||||
|
|
||||||
self.p._state = 'starting'
|
self.p._state = 'starting'
|
||||||
state['sleeps'] = 0
|
state['sleeps'] = 0
|
||||||
@@ -800,23 +865,13 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
state['sleeps'] = 0
|
state['sleeps'] = 0
|
||||||
state['final_return'] = 0
|
state['final_return'] = 0
|
||||||
self.assertFalse(self.p.wait_for_startup(timeout=2))
|
self.assertFalse(self.p.wait_for_startup(timeout=2))
|
||||||
self.assertEquals(state['sleeps'], 3)
|
self.assertEqual(state['sleeps'], 3)
|
||||||
|
|
||||||
with patch.object(Postgresql, 'check_startup_state_changed', Mock(return_value=False)):
|
with patch.object(Postgresql, 'check_startup_state_changed', Mock(return_value=False)):
|
||||||
self.p.cancel()
|
self.p.cancel()
|
||||||
self.p._state = 'starting'
|
self.p._state = 'starting'
|
||||||
self.assertIsNone(self.p.wait_for_startup())
|
self.assertIsNone(self.p.wait_for_startup())
|
||||||
|
|
||||||
def test_read_pid_file(self):
|
|
||||||
pidfile = os.path.join(self.data_dir, 'postmaster.pid')
|
|
||||||
if os.path.exists(pidfile):
|
|
||||||
os.remove(pidfile)
|
|
||||||
self.assertEquals(self.p._read_pid_file(), {})
|
|
||||||
with open(pidfile, 'w') as fd:
|
|
||||||
fd.write("123\n/foo/bar\n123456789\n5432")
|
|
||||||
self.assertEquals(self.p._read_pid_file(), {"pid": "123", "data_dir": "/foo/bar",
|
|
||||||
"start_time": "123456789", "port": "5432"})
|
|
||||||
|
|
||||||
def test_pick_sync_standby(self):
|
def test_pick_sync_standby(self):
|
||||||
cluster = Cluster(True, None, self.leader, 0, [self.me, self.other, self.leadermem], None,
|
cluster = Cluster(True, None, self.leader, 0, [self.me, self.other, self.leadermem], None,
|
||||||
SyncState(0, self.me.name, self.leadermem.name), None)
|
SyncState(0, self.me.name, self.leadermem.name), None)
|
||||||
@@ -826,30 +881,30 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
(self.me.name, 'streaming', 'async'),
|
(self.me.name, 'streaming', 'async'),
|
||||||
(self.other.name, 'streaming', 'async'),
|
(self.other.name, 'streaming', 'async'),
|
||||||
]):
|
]):
|
||||||
self.assertEquals(self.p.pick_synchronous_standby(cluster), (self.leadermem.name, True))
|
self.assertEqual(self.p.pick_synchronous_standby(cluster), (self.leadermem.name, True))
|
||||||
|
|
||||||
with patch.object(Postgresql, "query", return_value=[
|
with patch.object(Postgresql, "query", return_value=[
|
||||||
(self.me.name, 'streaming', 'async'),
|
(self.me.name, 'streaming', 'async'),
|
||||||
(self.leadermem.name, 'streaming', 'potential'),
|
(self.leadermem.name, 'streaming', 'potential'),
|
||||||
(self.other.name, 'streaming', 'async'),
|
(self.other.name, 'streaming', 'async'),
|
||||||
]):
|
]):
|
||||||
self.assertEquals(self.p.pick_synchronous_standby(cluster), (self.leadermem.name, False))
|
self.assertEqual(self.p.pick_synchronous_standby(cluster), (self.leadermem.name, False))
|
||||||
|
|
||||||
with patch.object(Postgresql, "query", return_value=[
|
with patch.object(Postgresql, "query", return_value=[
|
||||||
(self.me.name, 'streaming', 'async'),
|
(self.me.name, 'streaming', 'async'),
|
||||||
(self.other.name, 'streaming', 'async'),
|
(self.other.name, 'streaming', 'async'),
|
||||||
]):
|
]):
|
||||||
self.assertEquals(self.p.pick_synchronous_standby(cluster), (self.me.name, False))
|
self.assertEqual(self.p.pick_synchronous_standby(cluster), (self.me.name, False))
|
||||||
|
|
||||||
with patch.object(Postgresql, "query", return_value=[
|
with patch.object(Postgresql, "query", return_value=[
|
||||||
('missing', 'streaming', 'sync'),
|
('missing', 'streaming', 'sync'),
|
||||||
(self.me.name, 'streaming', 'async'),
|
(self.me.name, 'streaming', 'async'),
|
||||||
(self.other.name, 'streaming', 'async'),
|
(self.other.name, 'streaming', 'async'),
|
||||||
]):
|
]):
|
||||||
self.assertEquals(self.p.pick_synchronous_standby(cluster), (self.me.name, False))
|
self.assertEqual(self.p.pick_synchronous_standby(cluster), (self.me.name, False))
|
||||||
|
|
||||||
with patch.object(Postgresql, "query", return_value=[]):
|
with patch.object(Postgresql, "query", return_value=[]):
|
||||||
self.assertEquals(self.p.pick_synchronous_standby(cluster), (None, False))
|
self.assertEqual(self.p.pick_synchronous_standby(cluster), (None, False))
|
||||||
|
|
||||||
def test_set_sync_standby(self):
|
def test_set_sync_standby(self):
|
||||||
def value_in_conf():
|
def value_in_conf():
|
||||||
@@ -860,22 +915,22 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
|
|
||||||
mock_reload = self.p.reload = Mock()
|
mock_reload = self.p.reload = Mock()
|
||||||
self.p.set_synchronous_standby('n1')
|
self.p.set_synchronous_standby('n1')
|
||||||
self.assertEquals(value_in_conf(), "synchronous_standby_names = 'n1'")
|
self.assertEqual(value_in_conf(), "synchronous_standby_names = 'n1'")
|
||||||
mock_reload.assert_called()
|
mock_reload.assert_called()
|
||||||
|
|
||||||
mock_reload.reset_mock()
|
mock_reload.reset_mock()
|
||||||
self.p.set_synchronous_standby('n1')
|
self.p.set_synchronous_standby('n1')
|
||||||
mock_reload.assert_not_called()
|
mock_reload.assert_not_called()
|
||||||
self.assertEquals(value_in_conf(), "synchronous_standby_names = 'n1'")
|
self.assertEqual(value_in_conf(), "synchronous_standby_names = 'n1'")
|
||||||
|
|
||||||
self.p.set_synchronous_standby('n2')
|
self.p.set_synchronous_standby('n2')
|
||||||
mock_reload.assert_called()
|
mock_reload.assert_called()
|
||||||
self.assertEquals(value_in_conf(), "synchronous_standby_names = 'n2'")
|
self.assertEqual(value_in_conf(), "synchronous_standby_names = 'n2'")
|
||||||
|
|
||||||
mock_reload.reset_mock()
|
mock_reload.reset_mock()
|
||||||
self.p.set_synchronous_standby(None)
|
self.p.set_synchronous_standby(None)
|
||||||
mock_reload.assert_called()
|
mock_reload.assert_called()
|
||||||
self.assertEquals(value_in_conf(), None)
|
self.assertEqual(value_in_conf(), None)
|
||||||
|
|
||||||
def test_get_server_parameters(self):
|
def test_get_server_parameters(self):
|
||||||
config = {'synchronous_mode': True, 'parameters': {'wal_level': 'hot_standby'}, 'listen': '0'}
|
config = {'synchronous_mode': True, 'parameters': {'wal_level': 'hot_standby'}, 'listen': '0'}
|
||||||
@@ -909,8 +964,8 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
"--wal_log_hints=on" "--max_wal_senders=5" "--max_replication_slots=5"\n')
|
"--wal_log_hints=on" "--max_wal_senders=5" "--max_replication_slots=5"\n')
|
||||||
with patch.object(builtins, 'open', m):
|
with patch.object(builtins, 'open', m):
|
||||||
data = self.p.read_postmaster_opts()
|
data = self.p.read_postmaster_opts()
|
||||||
self.assertEquals(data['wal_level'], 'hot_standby')
|
self.assertEqual(data['wal_level'], 'hot_standby')
|
||||||
self.assertEquals(int(data['max_replication_slots']), 5)
|
self.assertEqual(int(data['max_replication_slots']), 5)
|
||||||
self.assertEqual(data.get('D'), None)
|
self.assertEqual(data.get('D'), None)
|
||||||
|
|
||||||
m.side_effect = IOError
|
m.side_effect = IOError
|
||||||
@@ -920,7 +975,7 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
@patch('subprocess.Popen')
|
@patch('subprocess.Popen')
|
||||||
def test_single_user_mode(self, subprocess_popen_mock):
|
def test_single_user_mode(self, subprocess_popen_mock):
|
||||||
subprocess_popen_mock.return_value.wait.return_value = 0
|
subprocess_popen_mock.return_value.wait.return_value = 0
|
||||||
self.assertEquals(self.p.single_user_mode('CHECKPOINT', {'archive_mode': 'on'}), 0)
|
self.assertEqual(self.p.single_user_mode('CHECKPOINT', {'archive_mode': 'on'}), 0)
|
||||||
|
|
||||||
@patch('os.listdir', Mock(side_effect=[OSError, ['a', 'b']]))
|
@patch('os.listdir', Mock(side_effect=[OSError, ['a', 'b']]))
|
||||||
@patch('os.unlink', Mock(side_effect=OSError))
|
@patch('os.unlink', Mock(side_effect=OSError))
|
||||||
@@ -938,14 +993,14 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
self.assertTrue(self.p.fix_cluster_state())
|
self.assertTrue(self.p.fix_cluster_state())
|
||||||
|
|
||||||
def test_replica_cached_timeline(self):
|
def test_replica_cached_timeline(self):
|
||||||
self.assertEquals(self.p.replica_cached_timeline(1), 2)
|
self.assertEqual(self.p.replica_cached_timeline(1), 2)
|
||||||
|
|
||||||
def test_get_master_timeline(self):
|
def test_get_master_timeline(self):
|
||||||
self.assertEquals(self.p.get_master_timeline(), 1)
|
self.assertEqual(self.p.get_master_timeline(), 1)
|
||||||
|
|
||||||
def test_cancellable_subprocess_call(self):
|
def test_cancellable_subprocess_call(self):
|
||||||
self.p.cancel()
|
self.p.cancel()
|
||||||
self.assertRaises(PostgresException, self.p.cancellable_subprocess_call)
|
self.assertRaises(PostgresException, self.p.cancellable_subprocess_call, communicate_input=None)
|
||||||
|
|
||||||
@patch('patroni.postgresql.polling_loop', Mock(return_value=[0, 0]))
|
@patch('patroni.postgresql.polling_loop', Mock(return_value=[0, 0]))
|
||||||
def test_cancel(self):
|
def test_cancel(self):
|
||||||
@@ -954,3 +1009,14 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
self.p.cancel()
|
self.p.cancel()
|
||||||
type(self.p._cancellable).returncode = PropertyMock(side_effect=[None, -15])
|
type(self.p._cancellable).returncode = PropertyMock(side_effect=[None, -15])
|
||||||
self.p.cancel()
|
self.p.cancel()
|
||||||
|
|
||||||
|
@patch.object(Postgresql, 'get_postgres_role_from_data_directory', Mock(return_value='replica'))
|
||||||
|
def test__build_effective_configuration(self):
|
||||||
|
with patch.object(Postgresql, 'controldata',
|
||||||
|
Mock(return_value={'max_connections setting': '200',
|
||||||
|
'max_worker_processes setting': '20',
|
||||||
|
'max_prepared_xacts setting': '100',
|
||||||
|
'max_locks_per_xact setting': '100'})):
|
||||||
|
self.p.cancel()
|
||||||
|
self.assertFalse(self.p.start())
|
||||||
|
self.assertTrue(self.p.pending_restart)
|
||||||
|
|||||||
+47
-22
@@ -1,8 +1,9 @@
|
|||||||
|
import psutil
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from mock import Mock, patch
|
from mock import Mock, patch, mock_open
|
||||||
from patroni.postmaster import PostmasterProcess
|
from patroni.postmaster import PostmasterProcess
|
||||||
import psutil
|
from six.moves import builtins
|
||||||
|
|
||||||
|
|
||||||
class TestPostmasterProcess(unittest.TestCase):
|
class TestPostmasterProcess(unittest.TestCase):
|
||||||
@@ -13,31 +14,39 @@ class TestPostmasterProcess(unittest.TestCase):
|
|||||||
|
|
||||||
@patch('psutil.Process.create_time')
|
@patch('psutil.Process.create_time')
|
||||||
@patch('psutil.Process.__init__')
|
@patch('psutil.Process.__init__')
|
||||||
def test_from_pidfile(self, mock_init, mock_create_time):
|
@patch('patroni.postmaster.PostmasterProcess._read_postmaster_pidfile')
|
||||||
|
def test_from_pidfile(self, mock_read, mock_init, mock_create_time):
|
||||||
mock_init.side_effect = psutil.NoSuchProcess(123)
|
mock_init.side_effect = psutil.NoSuchProcess(123)
|
||||||
self.assertEquals(PostmasterProcess.from_pidfile({}), None)
|
mock_read.return_value = {}
|
||||||
self.assertEquals(PostmasterProcess.from_pidfile({"pid": "foo"}), None)
|
self.assertIsNone(PostmasterProcess.from_pidfile(''))
|
||||||
self.assertEquals(PostmasterProcess.from_pidfile({"pid": "123"}), None)
|
mock_read.return_value = {"pid": "foo"}
|
||||||
|
self.assertIsNone(PostmasterProcess.from_pidfile(''))
|
||||||
|
mock_read.return_value = {"pid": "123"}
|
||||||
|
self.assertIsNone(PostmasterProcess.from_pidfile(''))
|
||||||
|
|
||||||
mock_init.side_effect = None
|
mock_init.side_effect = None
|
||||||
with patch.object(psutil.Process, 'pid', 123), \
|
with patch.object(psutil.Process, 'pid', 123), \
|
||||||
patch.object(psutil.Process, 'parent', return_value=124), \
|
patch.object(psutil.Process, 'ppid', return_value=124), \
|
||||||
patch('os.getpid', return_value=125) as mock_ospid, \
|
patch('os.getpid', return_value=125) as mock_ospid, \
|
||||||
patch('os.getppid', return_value=126):
|
patch('os.getppid', return_value=126):
|
||||||
|
|
||||||
self.assertNotEquals(PostmasterProcess.from_pidfile({"pid": "123"}), None)
|
self.assertIsNotNone(PostmasterProcess.from_pidfile(''))
|
||||||
|
|
||||||
mock_create_time.return_value = 100000
|
mock_create_time.return_value = 100000
|
||||||
self.assertEquals(PostmasterProcess.from_pidfile({"pid": "123", "start_time": "200000"}), None)
|
mock_read.return_value = {"pid": "123", "start_time": "200000"}
|
||||||
self.assertNotEquals(PostmasterProcess.from_pidfile({"pid": "123", "start_time": "foobar"}), None)
|
self.assertIsNone(PostmasterProcess.from_pidfile(''))
|
||||||
|
|
||||||
|
mock_read.return_value = {"pid": "123", "start_time": "foobar"}
|
||||||
|
self.assertIsNotNone(PostmasterProcess.from_pidfile(''))
|
||||||
|
|
||||||
mock_ospid.return_value = 123
|
mock_ospid.return_value = 123
|
||||||
self.assertEquals(PostmasterProcess.from_pidfile({"pid": "123", "start_time": "100000"}), None)
|
mock_read.return_value = {"pid": "123", "start_time": "100000"}
|
||||||
|
self.assertIsNone(PostmasterProcess.from_pidfile(''))
|
||||||
|
|
||||||
@patch('psutil.Process.__init__')
|
@patch('psutil.Process.__init__')
|
||||||
def test_from_pid(self, mock_init):
|
def test_from_pid(self, mock_init):
|
||||||
mock_init.side_effect = psutil.NoSuchProcess(123)
|
mock_init.side_effect = psutil.NoSuchProcess(123)
|
||||||
self.assertEquals(PostmasterProcess.from_pid(123), None)
|
self.assertEqual(PostmasterProcess.from_pid(123), None)
|
||||||
mock_init.side_effect = None
|
mock_init.side_effect = None
|
||||||
self.assertNotEquals(PostmasterProcess.from_pid(123), None)
|
self.assertNotEquals(PostmasterProcess.from_pid(123), None)
|
||||||
|
|
||||||
@@ -46,13 +55,13 @@ class TestPostmasterProcess(unittest.TestCase):
|
|||||||
@patch('psutil.Process.pid', Mock(return_value=123))
|
@patch('psutil.Process.pid', Mock(return_value=123))
|
||||||
def test_signal_stop(self, mock_send_signal):
|
def test_signal_stop(self, mock_send_signal):
|
||||||
proc = PostmasterProcess(-123)
|
proc = PostmasterProcess(-123)
|
||||||
self.assertEquals(proc.signal_stop('immediate'), False)
|
self.assertEqual(proc.signal_stop('immediate'), False)
|
||||||
|
|
||||||
mock_send_signal.side_effect = [None, psutil.NoSuchProcess(123), psutil.AccessDenied()]
|
mock_send_signal.side_effect = [None, psutil.NoSuchProcess(123), psutil.AccessDenied()]
|
||||||
proc = PostmasterProcess(123)
|
proc = PostmasterProcess(123)
|
||||||
self.assertEquals(proc.signal_stop('immediate'), None)
|
self.assertEqual(proc.signal_stop('immediate'), None)
|
||||||
self.assertEquals(proc.signal_stop('immediate'), True)
|
self.assertEqual(proc.signal_stop('immediate'), True)
|
||||||
self.assertEquals(proc.signal_stop('immediate'), False)
|
self.assertEqual(proc.signal_stop('immediate'), False)
|
||||||
|
|
||||||
@patch('psutil.Process.__init__', Mock())
|
@patch('psutil.Process.__init__', Mock())
|
||||||
@patch('psutil.wait_procs')
|
@patch('psutil.wait_procs')
|
||||||
@@ -61,18 +70,34 @@ class TestPostmasterProcess(unittest.TestCase):
|
|||||||
c1.cmdline = Mock(return_value=["postgres: startup process"])
|
c1.cmdline = Mock(return_value=["postgres: startup process"])
|
||||||
c2 = Mock()
|
c2 = Mock()
|
||||||
c2.cmdline = Mock(return_value=["postgres: postgres postgres [local] idle"])
|
c2.cmdline = Mock(return_value=["postgres: postgres postgres [local] idle"])
|
||||||
with patch('psutil.Process.children', Mock(return_value=[c1, c2])):
|
c3 = Mock()
|
||||||
|
c3.cmdline = Mock(side_effect=psutil.NoSuchProcess(123))
|
||||||
|
with patch('psutil.Process.children', Mock(return_value=[c1, c2, c3])):
|
||||||
proc = PostmasterProcess(123)
|
proc = PostmasterProcess(123)
|
||||||
self.assertIsNone(proc.wait_for_user_backends_to_close())
|
self.assertIsNone(proc.wait_for_user_backends_to_close())
|
||||||
mock_wait.assert_called_with([c2])
|
mock_wait.assert_called_with([c2])
|
||||||
|
|
||||||
|
c3.cmdline = Mock(side_effect=psutil.AccessDenied(123))
|
||||||
|
with patch('psutil.Process.children', Mock(return_value=[c3])):
|
||||||
|
proc = PostmasterProcess(123)
|
||||||
|
self.assertIsNone(proc.wait_for_user_backends_to_close())
|
||||||
|
|
||||||
@patch('subprocess.Popen')
|
@patch('subprocess.Popen')
|
||||||
@patch.object(PostmasterProcess, 'from_pid')
|
@patch.object(PostmasterProcess, 'from_pid')
|
||||||
def test_start(self, mock_frompid, mock_popen):
|
@patch.object(PostmasterProcess, '_from_pidfile')
|
||||||
|
def test_start(self, mock_frompidfile, mock_frompid, mock_popen):
|
||||||
|
mock_frompidfile.return_value._is_postmaster_process.return_value = False
|
||||||
mock_frompid.return_value = "proc 123"
|
mock_frompid.return_value = "proc 123"
|
||||||
mock_popen.return_value.stdout.readline.return_value = '123'
|
mock_popen.return_value.stdout.readline.return_value = '123'
|
||||||
self.assertEquals(
|
self.assertEqual(PostmasterProcess.start('true', '/tmp', '/tmp/test.conf', []), "proc 123")
|
||||||
PostmasterProcess.start('/bin/true', '/tmp/', '/tmp/test.conf', ['--foo=bar', '--bar=baz']),
|
|
||||||
"proc 123"
|
|
||||||
)
|
|
||||||
mock_frompid.assert_called_with(123)
|
mock_frompid.assert_called_with(123)
|
||||||
|
|
||||||
|
mock_frompidfile.side_effect = psutil.NoSuchProcess(123)
|
||||||
|
self.assertEqual(PostmasterProcess.start('true', '/tmp', '/tmp/test.conf', []), "proc 123")
|
||||||
|
|
||||||
|
@patch('psutil.Process.__init__', Mock(side_effect=psutil.NoSuchProcess(123)))
|
||||||
|
def test_read_postmaster_pidfile(self):
|
||||||
|
with patch.object(builtins, 'open', Mock(side_effect=IOError)):
|
||||||
|
self.assertIsNone(PostmasterProcess.from_pidfile(''))
|
||||||
|
with patch.object(builtins, 'open', mock_open(read_data='123\n')):
|
||||||
|
self.assertIsNone(PostmasterProcess.from_pidfile(''))
|
||||||
|
|||||||
+5
-5
@@ -8,7 +8,7 @@ from patroni.utils import Retry, RetryFailedError, polling_loop
|
|||||||
class TestUtils(unittest.TestCase):
|
class TestUtils(unittest.TestCase):
|
||||||
|
|
||||||
def test_polling_loop(self):
|
def test_polling_loop(self):
|
||||||
self.assertEquals(list(polling_loop(0.001, interval=0.001)), [0])
|
self.assertEqual(list(polling_loop(0.001, interval=0.001)), [0])
|
||||||
|
|
||||||
|
|
||||||
@patch('time.sleep', Mock())
|
@patch('time.sleep', Mock())
|
||||||
@@ -29,21 +29,21 @@ class TestRetrySleeper(unittest.TestCase):
|
|||||||
def test_reset(self):
|
def test_reset(self):
|
||||||
retry = Retry(delay=0, max_tries=2)
|
retry = Retry(delay=0, max_tries=2)
|
||||||
retry(self._fail())
|
retry(self._fail())
|
||||||
self.assertEquals(retry._attempts, 1)
|
self.assertEqual(retry._attempts, 1)
|
||||||
retry.reset()
|
retry.reset()
|
||||||
self.assertEquals(retry._attempts, 0)
|
self.assertEqual(retry._attempts, 0)
|
||||||
|
|
||||||
def test_too_many_tries(self):
|
def test_too_many_tries(self):
|
||||||
retry = Retry(delay=0)
|
retry = Retry(delay=0)
|
||||||
self.assertRaises(RetryFailedError, retry, self._fail(times=999))
|
self.assertRaises(RetryFailedError, retry, self._fail(times=999))
|
||||||
self.assertEquals(retry._attempts, 1)
|
self.assertEqual(retry._attempts, 1)
|
||||||
|
|
||||||
def test_maximum_delay(self):
|
def test_maximum_delay(self):
|
||||||
retry = Retry(delay=10, max_tries=100)
|
retry = Retry(delay=10, max_tries=100)
|
||||||
retry(self._fail(times=10))
|
retry(self._fail(times=10))
|
||||||
self.assertTrue(retry._cur_delay < 4000, retry._cur_delay)
|
self.assertTrue(retry._cur_delay < 4000, retry._cur_delay)
|
||||||
# gevent's sleep function is picky about the type
|
# gevent's sleep function is picky about the type
|
||||||
self.assertEquals(type(retry._cur_delay), float)
|
self.assertEqual(type(retry._cur_delay), float)
|
||||||
|
|
||||||
def test_deadline(self):
|
def test_deadline(self):
|
||||||
retry = Retry(deadline=0.0001)
|
retry = Retry(deadline=0.0001)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from patroni.scripts import wale_restore
|
|||||||
from patroni.scripts.wale_restore import WALERestore, main as _main, get_major_version
|
from patroni.scripts.wale_restore import WALERestore, main as _main, get_major_version
|
||||||
from six.moves import builtins
|
from six.moves import builtins
|
||||||
from test_postgresql import MockConnect, psycopg2_connect
|
from test_postgresql import MockConnect, psycopg2_connect
|
||||||
|
from threading import current_thread
|
||||||
|
|
||||||
|
|
||||||
wale_output_header = (
|
wale_output_header = (
|
||||||
@@ -42,6 +43,13 @@ class TestWALERestore(unittest.TestCase):
|
|||||||
'/etc', 100, 100, 1, 0, WALE_TEST_RETRIES)
|
'/etc', 100, 100, 1, 0, WALE_TEST_RETRIES)
|
||||||
|
|
||||||
def test_should_use_s3_to_create_replica(self):
|
def test_should_use_s3_to_create_replica(self):
|
||||||
|
self.__thread_ident = current_thread().ident
|
||||||
|
sleeps = [0]
|
||||||
|
|
||||||
|
def mock_sleep(*args):
|
||||||
|
if current_thread().ident == self.__thread_ident:
|
||||||
|
sleeps[0] += 1
|
||||||
|
|
||||||
self.assertTrue(self.wale_restore.should_use_s3_to_create_replica())
|
self.assertTrue(self.wale_restore.should_use_s3_to_create_replica())
|
||||||
with patch.object(MockConnect, 'server_version', PropertyMock(return_value=100000)):
|
with patch.object(MockConnect, 'server_version', PropertyMock(return_value=100000)):
|
||||||
self.assertTrue(self.wale_restore.should_use_s3_to_create_replica())
|
self.assertTrue(self.wale_restore.should_use_s3_to_create_replica())
|
||||||
@@ -55,13 +63,11 @@ class TestWALERestore(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
|
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
|
||||||
|
|
||||||
with patch('time.sleep', Mock(return_value=None)) as mock_sleep:
|
with patch('time.sleep', mock_sleep):
|
||||||
self.wale_restore.no_master = 1
|
self.wale_restore.no_master = 1
|
||||||
assert self.wale_restore.should_use_s3_to_create_replica()
|
self.assertTrue(self.wale_restore.should_use_s3_to_create_replica())
|
||||||
# verify retries
|
# verify retries
|
||||||
mock_sleep.assert_has_calls(
|
self.assertEqual(sleeps[0], WALE_TEST_RETRIES)
|
||||||
[((wale_restore.RETRY_SLEEP_INTERVAL,),)] * WALE_TEST_RETRIES
|
|
||||||
)
|
|
||||||
|
|
||||||
self.wale_restore.master_connection = ''
|
self.wale_restore.master_connection = ''
|
||||||
self.assertTrue(self.wale_restore.should_use_s3_to_create_replica())
|
self.assertTrue(self.wale_restore.should_use_s3_to_create_replica())
|
||||||
@@ -104,13 +110,20 @@ class TestWALERestore(unittest.TestCase):
|
|||||||
|
|
||||||
@patch('sys.exit', Mock())
|
@patch('sys.exit', Mock())
|
||||||
def test_main(self):
|
def test_main(self):
|
||||||
|
self.__thread_ident = current_thread().ident
|
||||||
|
sleeps = [0]
|
||||||
|
|
||||||
|
def mock_sleep(*args):
|
||||||
|
if current_thread().ident == self.__thread_ident:
|
||||||
|
sleeps[0] += 1
|
||||||
|
|
||||||
with patch.object(WALERestore, 'run', Mock(return_value=0)):
|
with patch.object(WALERestore, 'run', Mock(return_value=0)):
|
||||||
self.assertEqual(_main(), 0)
|
self.assertEqual(_main(), 0)
|
||||||
|
|
||||||
with patch.object(WALERestore, 'run', Mock(return_value=1)), \
|
with patch.object(WALERestore, 'run', Mock(return_value=1)), \
|
||||||
patch('time.sleep', Mock(return_value=None)) as mock_sleep:
|
patch('time.sleep', mock_sleep):
|
||||||
self.assertEqual(_main(), 1)
|
self.assertEqual(_main(), 1)
|
||||||
assert mock_sleep.call_count == WALE_TEST_RETRIES
|
self.assertTrue(sleeps[0], WALE_TEST_RETRIES)
|
||||||
|
|
||||||
@patch('os.path.isfile', Mock(return_value=True))
|
@patch('os.path.isfile', Mock(return_value=True))
|
||||||
def test_get_major_version(self):
|
def test_get_major_version(self):
|
||||||
|
|||||||
+11
-11
@@ -73,14 +73,14 @@ class TestWatchdog(unittest.TestCase):
|
|||||||
@patch.object(LinuxWatchdogDevice, 'can_be_disabled', PropertyMock(return_value=True))
|
@patch.object(LinuxWatchdogDevice, 'can_be_disabled', PropertyMock(return_value=True))
|
||||||
def test_unsafe_timeout_disable_watchdog_and_exit(self):
|
def test_unsafe_timeout_disable_watchdog_and_exit(self):
|
||||||
watchdog = Watchdog({'ttl': 30, 'loop_wait': 15, 'watchdog': {'mode': 'required', 'safety_margin': -1}})
|
watchdog = Watchdog({'ttl': 30, 'loop_wait': 15, 'watchdog': {'mode': 'required', 'safety_margin': -1}})
|
||||||
self.assertEquals(watchdog.activate(), False)
|
self.assertEqual(watchdog.activate(), False)
|
||||||
self.assertEquals(watchdog.is_running, False)
|
self.assertEqual(watchdog.is_running, False)
|
||||||
|
|
||||||
@patch('platform.system', Mock(return_value='Linux'))
|
@patch('platform.system', Mock(return_value='Linux'))
|
||||||
@patch.object(LinuxWatchdogDevice, 'get_timeout', Mock(return_value=16))
|
@patch.object(LinuxWatchdogDevice, 'get_timeout', Mock(return_value=16))
|
||||||
def test_timeout_does_not_ensure_safe_termination(self):
|
def test_timeout_does_not_ensure_safe_termination(self):
|
||||||
Watchdog({'ttl': 30, 'loop_wait': 15, 'watchdog': {'mode': 'auto', 'safety_margin': -1}}).activate()
|
Watchdog({'ttl': 30, 'loop_wait': 15, 'watchdog': {'mode': 'auto', 'safety_margin': -1}}).activate()
|
||||||
self.assertEquals(len(mock_devices), 2)
|
self.assertEqual(len(mock_devices), 2)
|
||||||
|
|
||||||
@patch('platform.system', Mock(return_value='Linux'))
|
@patch('platform.system', Mock(return_value='Linux'))
|
||||||
@patch.object(Watchdog, 'is_running', PropertyMock(return_value=False))
|
@patch.object(Watchdog, 'is_running', PropertyMock(return_value=False))
|
||||||
@@ -99,29 +99,29 @@ class TestWatchdog(unittest.TestCase):
|
|||||||
watchdog = Watchdog({'ttl': 30, 'loop_wait': 10, 'watchdog': {'mode': 'required'}})
|
watchdog = Watchdog({'ttl': 30, 'loop_wait': 10, 'watchdog': {'mode': 'required'}})
|
||||||
watchdog.activate()
|
watchdog.activate()
|
||||||
|
|
||||||
self.assertEquals(len(mock_devices), 2)
|
self.assertEqual(len(mock_devices), 2)
|
||||||
device = mock_devices[-1]
|
device = mock_devices[-1]
|
||||||
self.assertTrue(device.open)
|
self.assertTrue(device.open)
|
||||||
|
|
||||||
self.assertEquals(device.timeout, 24)
|
self.assertEqual(device.timeout, 24)
|
||||||
|
|
||||||
watchdog.keepalive()
|
watchdog.keepalive()
|
||||||
self.assertEquals(len(device.writes), 1)
|
self.assertEqual(len(device.writes), 1)
|
||||||
|
|
||||||
watchdog.disable()
|
watchdog.disable()
|
||||||
self.assertFalse(device.open)
|
self.assertFalse(device.open)
|
||||||
self.assertEquals(device.writes[-1], b'V')
|
self.assertEqual(device.writes[-1], b'V')
|
||||||
|
|
||||||
def test_invalid_timings(self):
|
def test_invalid_timings(self):
|
||||||
watchdog = Watchdog({'ttl': 30, 'loop_wait': 20, 'watchdog': {'mode': 'automatic', 'safety_margin': -1}})
|
watchdog = Watchdog({'ttl': 30, 'loop_wait': 20, 'watchdog': {'mode': 'automatic', 'safety_margin': -1}})
|
||||||
watchdog.activate()
|
watchdog.activate()
|
||||||
self.assertEquals(len(mock_devices), 1)
|
self.assertEqual(len(mock_devices), 1)
|
||||||
self.assertFalse(watchdog.is_running)
|
self.assertFalse(watchdog.is_running)
|
||||||
|
|
||||||
def test_parse_mode(self):
|
def test_parse_mode(self):
|
||||||
with patch('patroni.watchdog.base.logger.warning', new_callable=Mock()) as warning_mock:
|
with patch('patroni.watchdog.base.logger.warning', new_callable=Mock()) as warning_mock:
|
||||||
watchdog = Watchdog({'ttl': 30, 'loop_wait': 10, 'watchdog': {'mode': 'bad'}})
|
watchdog = Watchdog({'ttl': 30, 'loop_wait': 10, 'watchdog': {'mode': 'bad'}})
|
||||||
self.assertEquals(watchdog.config.mode, 'off')
|
self.assertEqual(watchdog.config.mode, 'off')
|
||||||
warning_mock.assert_called_once()
|
warning_mock.assert_called_once()
|
||||||
|
|
||||||
@patch('platform.system', Mock(return_value='Unknown'))
|
@patch('platform.system', Mock(return_value='Unknown'))
|
||||||
@@ -170,7 +170,7 @@ class TestNullWatchdog(unittest.TestCase):
|
|||||||
watchdog = NullWatchdog()
|
watchdog = NullWatchdog()
|
||||||
self.assertTrue(watchdog.can_be_disabled)
|
self.assertTrue(watchdog.can_be_disabled)
|
||||||
self.assertRaises(WatchdogError, watchdog.set_timeout, 1)
|
self.assertRaises(WatchdogError, watchdog.set_timeout, 1)
|
||||||
self.assertEquals(watchdog.describe(), 'NullWatchdog')
|
self.assertEqual(watchdog.describe(), 'NullWatchdog')
|
||||||
self.assertIsInstance(NullWatchdog.from_config({}), NullWatchdog)
|
self.assertIsInstance(NullWatchdog.from_config({}), NullWatchdog)
|
||||||
|
|
||||||
|
|
||||||
@@ -210,7 +210,7 @@ class TestLinuxWatchdogDevice(unittest.TestCase):
|
|||||||
self.assertRaises(WatchdogError, self.impl.get_timeout)
|
self.assertRaises(WatchdogError, self.impl.get_timeout)
|
||||||
self.assertRaises(WatchdogError, self.impl.set_timeout, 10)
|
self.assertRaises(WatchdogError, self.impl.set_timeout, 10)
|
||||||
# We still try to output a reasonable string even if getting info errors
|
# We still try to output a reasonable string even if getting info errors
|
||||||
self.assertEquals(self.impl.describe(), "Linux watchdog device")
|
self.assertEqual(self.impl.describe(), "Linux watchdog device")
|
||||||
|
|
||||||
@patch('os.open', Mock(side_effect=OSError))
|
@patch('os.open', Mock(side_effect=OSError))
|
||||||
def test_open(self):
|
def test_open(self):
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ class TestPatroniSequentialThreadingHandler(unittest.TestCase):
|
|||||||
def test_create_connection(self):
|
def test_create_connection(self):
|
||||||
self.assertIsNotNone(self.handler.create_connection(()))
|
self.assertIsNotNone(self.handler.create_connection(()))
|
||||||
self.assertIsNotNone(self.handler.create_connection((), 40))
|
self.assertIsNotNone(self.handler.create_connection((), 40))
|
||||||
|
self.assertIsNotNone(self.handler.create_connection(timeout=40))
|
||||||
|
|
||||||
|
|
||||||
class TestZooKeeper(unittest.TestCase):
|
class TestZooKeeper(unittest.TestCase):
|
||||||
@@ -156,7 +157,7 @@ class TestZooKeeper(unittest.TestCase):
|
|||||||
self.zk.set_failover_value('Exception')
|
self.zk.set_failover_value('Exception')
|
||||||
|
|
||||||
def test_set_config_value(self):
|
def test_set_config_value(self):
|
||||||
self.zk.set_config_value('')
|
self.zk.set_config_value('', 1)
|
||||||
self.zk.set_config_value('ok')
|
self.zk.set_config_value('ok')
|
||||||
self.zk.set_config_value('Exception')
|
self.zk.set_config_value('Exception')
|
||||||
|
|
||||||
@@ -179,7 +180,8 @@ class TestZooKeeper(unittest.TestCase):
|
|||||||
self.zk.touch_member({'retry': 'retry'})
|
self.zk.touch_member({'retry': 'retry'})
|
||||||
self.zk._fetch_cluster = True
|
self.zk._fetch_cluster = True
|
||||||
self.zk.get_cluster()
|
self.zk.get_cluster()
|
||||||
self.zk.touch_member({'retry': 'retry'})
|
self.zk.touch_member({'conn_url': 'postgres://repuser:rep-pass@localhost:5434/postgres',
|
||||||
|
'api_url': 'http://127.0.0.1:8009/patroni'})
|
||||||
|
|
||||||
def test_take_leader(self):
|
def test_take_leader(self):
|
||||||
self.zk.take_leader()
|
self.zk.take_leader()
|
||||||
|
|||||||
Reference in New Issue
Block a user