mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-31 00:29:29 +00:00
Compare commits
+1
-1
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
-----------------------
|
-----------------------
|
||||||
@@ -55,7 +57,7 @@ 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`.
|
||||||
|
|||||||
+1
-1
@@ -87,7 +87,7 @@ 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``.
|
||||||
|
|||||||
@@ -3,6 +3,89 @@
|
|||||||
Release notes
|
Release notes
|
||||||
=============
|
=============
|
||||||
|
|
||||||
|
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
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ RUN export DEBIAN_FRONTEND=noninteractive \
|
|||||||
&& 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 \
|
&& pip install setuptools pip --upgrade \
|
||||||
&& pip install 'git+https://github.com/zalando/patroni.git@feature/k8s#egg=patroni[kubernetes]' \
|
&& pip install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
|
||||||
|
|
||||||
&& mkdir -p /home/postgres \
|
&& mkdir -p /home/postgres \
|
||||||
&& chown postgres:postgres /home/postgres \
|
&& chown postgres:postgres /home/postgres \
|
||||||
|
|||||||
@@ -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 .
|
||||||
@@ -120,3 +121,62 @@ 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
|
||||||
|
- apiGroups:
|
||||||
|
- ""
|
||||||
|
resources:
|
||||||
|
- endpoints
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
# the following three privileges are necessary only when using endpoints
|
||||||
|
- create
|
||||||
|
- list
|
||||||
|
- watch
|
||||||
|
- 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
|
||||||
|
|||||||
+4
-2
@@ -135,8 +135,10 @@ class Patroni(object):
|
|||||||
|
|
||||||
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:
|
||||||
|
|||||||
+27
-17
@@ -546,15 +546,14 @@ 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))
|
||||||
@@ -610,6 +609,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 +666,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 +687,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 +703,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 +718,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:
|
||||||
@@ -1091,7 +1101,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()
|
||||||
|
|||||||
+5
-2
@@ -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)
|
||||||
|
|||||||
+17
-13
@@ -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('/', '-')
|
||||||
|
|
||||||
@@ -362,7 +367,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)
|
||||||
|
|||||||
+24
-16
@@ -537,16 +537,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,7 +553,6 @@ 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'])
|
||||||
@@ -1079,8 +1074,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):
|
||||||
"""
|
"""
|
||||||
@@ -1161,7 +1158,7 @@ class Postgresql(object):
|
|||||||
""" return the contents of pg_controldata, or non-True value if pg_controldata call failed """
|
""" return the contents of pg_controldata, or non-True value if pg_controldata call failed """
|
||||||
result = {}
|
result = {}
|
||||||
# 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 not self.bootstrapping and self._version_file_exists() and self.state != 'creating replica':
|
||||||
try:
|
try:
|
||||||
data = subprocess.check_output([self._pgcommand('pg_controldata'), self._data_dir],
|
data = subprocess.check_output([self._pgcommand('pg_controldata'), self._data_dir],
|
||||||
env={'LANG': 'C', 'LC_ALL': 'C', 'PATH': os.environ['PATH']})
|
env={'LANG': 'C', 'LC_ALL': 'C', 'PATH': os.environ['PATH']})
|
||||||
@@ -1397,6 +1394,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 """
|
||||||
@@ -1546,6 +1544,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 +1552,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:
|
||||||
@@ -1790,10 +1790,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 +1813,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()
|
||||||
|
|||||||
+17
-5
@@ -82,11 +82,23 @@ 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
|
@classmethod
|
||||||
def start(cls, pgcommand, data_dir, conf, options):
|
def start(cls, pgcommand, data_dir, conf, options):
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
__version__ = '1.4'
|
__version__ = '1.4.3'
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -57,7 +57,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 +80,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()
|
||||||
|
|
||||||
|
|||||||
@@ -567,6 +567,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
|
||||||
@@ -945,7 +946,7 @@ class TestPostgresql(unittest.TestCase):
|
|||||||
|
|
||||||
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):
|
||||||
|
|||||||
@@ -61,11 +61,18 @@ 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):
|
def test_start(self, mock_frompid, mock_popen):
|
||||||
|
|||||||
Reference in New Issue
Block a user