From aa5a72fb3748aa1ecd8cf6e488661dd4b3108ed0 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Thu, 9 Jul 2015 15:45:10 +0200 Subject: [PATCH 01/32] Add on_start, on_stop, on_role_change, on_restart and on_reload callbacks. --- helpers/postgresql.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/helpers/postgresql.py b/helpers/postgresql.py index 560d7a98..a7c2f742 100644 --- a/helpers/postgresql.py +++ b/helpers/postgresql.py @@ -1,6 +1,7 @@ import logging import os import psycopg2 +import shlex import shutil import subprocess import sys @@ -42,6 +43,7 @@ class Postgresql: self.replication = config['replication'] self.superuser = config['superuser'] self.admin = config['admin'] + self.callback = config.get(['callback'], {}) self.recovery_conf = os.path.join(self.data_dir, 'recovery.conf') self.configuration_to_save = (os.path.join(self.data_dir, 'pg_hba.conf'), os.path.join(self.data_dir, 'postgresql.conf')) @@ -238,6 +240,16 @@ class Postgresql: def is_running(self): return subprocess.call(' '.join(self._pg_ctl) + ' status > /dev/null', shell=True) == 0 + def call_nowait(self, cb_name): + """ pick a callback command and call it without waiting for it to finish """ + if not self.callback or cb_name not in self.callback: + return False + cmd = self.callback[cb_name] + is_master = self.is_leader() + name = self.name + subprocess.Popen(shlex.split(cmd)+[is_master, name]) + return True + def start(self): if self.is_running(): self.load_replication_slots() @@ -251,18 +263,29 @@ class Postgresql: ret = subprocess.call(self._pg_ctl + ['start', '-o', self.server_options()]) == 0 ret and self.load_replication_slots() self.save_configuration_files() + if ret and 'on_start' in self.callback: + self.call_nowait('on_start') if self.on_change_callback: self.on_change_callback('replica' if os.path.exists(self.recovery_conf) else 'master') return ret def stop(self): - return subprocess.call(self._pg_ctl + ['stop', '-m', 'fast']) != 0 + ret = subprocess.call(self._pg_ctl + ['stop', '-m', 'fast']) + if ret == 0 and 'on_stop' in self.callback: + self.call_nowait('on_stop') + return ret != 0 def reload(self): - return subprocess.call(self._pg_ctl + ['reload']) == 0 + ret = subprocess.call(self._pg_ctl + ['reload']) + if ret == 0 and 'on_reload' in self.callback: + self.call_nowait('on_reload') + return ret == 0 def restart(self): - return subprocess.call(self._pg_ctl + ['restart', '-m', 'fast']) == 0 + ret = subprocess.call(self._pg_ctl + ['restart', '-m', 'fast']) + if ret == 0 and 'on_restart' in self.callback: + self.call_nowait('on_restart') + return ret == 0 def server_options(self): options = "--listen_addresses='{}' --port={}".format(self.listen_addresses, self.port) @@ -349,6 +372,8 @@ primary_conninfo = '{}' if not self.check_recovery_conf(leader): self.write_recovery_conf(leader) self.restart() + if self.on_change_callback['on_role_change']: + self.call_nowait('on_role_change') if self.on_change_callback: self.on_change_callback('replica') @@ -370,6 +395,8 @@ primary_conninfo = '{}' def promote(self): self.is_promoted = subprocess.call(self._pg_ctl + ['promote']) == 0 + if self.is_promoted and self.on_change_callback['on_role_change']: + self.call_nowait('on_role_change') if self.on_change_callback: self.on_change_callback('master') return self.is_promoted From 2c1627c2f3aec4befc535c0239097ffccaf89671 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Fri, 10 Jul 2015 10:33:07 +0200 Subject: [PATCH 02/32] get rid of yaml module, since AWS instance-identity natively sends JSON. --- helpers/aws.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/helpers/aws.py b/helpers/aws.py index b47e4e0f..26c65e89 100644 --- a/helpers/aws.py +++ b/helpers/aws.py @@ -1,8 +1,6 @@ import logging -import re import requests from requests.exceptions import RequestException -import yaml import boto.ec2 logger = logging.getLogger(__name__) @@ -27,7 +25,7 @@ class AWSConnection: return if r.ok: try: - content = yaml.load(r.content) + content = r.json() self.instance_id = content['instanceId'] self.region = content['region'] except Exception as e: From 78f0b16543541ce20b42c0ab97878b8b57b47d17 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Fri, 10 Jul 2015 10:57:24 +0200 Subject: [PATCH 03/32] Enable aws.py to run as a stand-alone program. --- helpers/aws.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/helpers/aws.py b/helpers/aws.py index 26c65e89..02ddd7fe 100644 --- a/helpers/aws.py +++ b/helpers/aws.py @@ -1,6 +1,7 @@ import logging import requests from requests.exceptions import RequestException +import sys import boto.ec2 logger = logging.getLogger(__name__) @@ -67,3 +68,15 @@ class AWSConnection: def on_role_change(self, new_role): ret = self._tag_ec2(new_role) return self._tag_ebs(new_role) and ret + + +if __name__ == '__main__': + if len(sys.argv) != 3: + print ("Usage: {0} action role name".format(sys.argv[0])) + return 1 + action, role, name = sys.argv[1:] + if action in ('on_start', 'on_stop', 'on_role_change'): + aws = gAWSConnection({'cluster_name': name}) + aws.on_role_change(role) + return 0 + return 2 From c0cc59093820bd134935e508281e67e76647df6b Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Fri, 10 Jul 2015 12:58:37 +0200 Subject: [PATCH 04/32] Remove usage of AWS.py from Patroni Functionality provided by that module will be achieved via callbacks. - fix a typo in aws.py - fix some mixups of old code and new callbacks in postgresql.py --- helpers/aws.py | 2 +- helpers/postgresql.py | 13 +++---------- patroni.py | 2 -- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/helpers/aws.py b/helpers/aws.py index 02ddd7fe..6a04da92 100644 --- a/helpers/aws.py +++ b/helpers/aws.py @@ -76,7 +76,7 @@ if __name__ == '__main__': return 1 action, role, name = sys.argv[1:] if action in ('on_start', 'on_stop', 'on_role_change'): - aws = gAWSConnection({'cluster_name': name}) + aws = AWSConnection({'cluster_name': name}) aws.on_role_change(role) return 0 return 2 diff --git a/helpers/postgresql.py b/helpers/postgresql.py index a7c2f742..5dff6578 100644 --- a/helpers/postgresql.py +++ b/helpers/postgresql.py @@ -35,7 +35,7 @@ def parseurl(url): class Postgresql: - def __init__(self, config, on_change_callback=None): + def __init__(self, config): self.config = config self.name = config['name'] self.listen_addresses, self.port = config['listen'].split(':') @@ -66,7 +66,6 @@ class Postgresql: self._connection = None self._cursor_holder = None self.members = [] # list of already existing replication slots - self.on_change_callback = on_change_callback def get_local_address(self): listen_addresses = self.listen_addresses.split(',') @@ -265,8 +264,6 @@ class Postgresql: self.save_configuration_files() if ret and 'on_start' in self.callback: self.call_nowait('on_start') - if self.on_change_callback: - self.on_change_callback('replica' if os.path.exists(self.recovery_conf) else 'master') return ret def stop(self): @@ -372,10 +369,8 @@ primary_conninfo = '{}' if not self.check_recovery_conf(leader): self.write_recovery_conf(leader) self.restart() - if self.on_change_callback['on_role_change']: + if 'on_role_change' in self.callback: self.call_nowait('on_role_change') - if self.on_change_callback: - self.on_change_callback('replica') def save_configuration_files(self): """ @@ -395,10 +390,8 @@ primary_conninfo = '{}' def promote(self): self.is_promoted = subprocess.call(self._pg_ctl + ['promote']) == 0 - if self.is_promoted and self.on_change_callback['on_role_change']: + if self.is_promoted and 'on_role_change' in self.callback: self.call_nowait('on_role_change') - if self.on_change_callback: - self.on_change_callback('master') return self.is_promoted def demote(self, leader): diff --git a/patroni.py b/patroni.py index d90e5c64..6f06a056 100755 --- a/patroni.py +++ b/patroni.py @@ -6,7 +6,6 @@ import time import yaml from helpers.api import RestApiServer -from helpers.aws import AWSConnection from helpers.etcd import Etcd from helpers.ha import Ha from helpers.postgresql import Postgresql @@ -18,7 +17,6 @@ class Patroni: def __init__(self, config): self.nap_time = config['loop_wait'] - self.aws = AWSConnection(config) self.postgresql = Postgresql(config['postgresql']) self.ha = Ha(self.postgresql, self.get_dcs(self.postgresql.name, config)) host, port = config['restapi']['listen'].split(':') From 6d0a9bbdc0030734f415e61801bc40928735590f Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Mon, 13 Jul 2015 10:25:42 +0200 Subject: [PATCH 05/32] create a new directory for callback scripts and move aws volume tags there. --- {helpers => scripts}/aws.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {helpers => scripts}/aws.py (100%) diff --git a/helpers/aws.py b/scripts/aws.py similarity index 100% rename from helpers/aws.py rename to scripts/aws.py From f734272f3f437bf0c38a92ff0183e4b2340bdd5a Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Mon, 13 Jul 2015 11:45:25 +0200 Subject: [PATCH 06/32] Process the command paths relative to the patroni dir correctly, fix the error of not supplying a callback action to the actual command. --- helpers/postgresql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/postgresql.py b/helpers/postgresql.py index 5dff6578..de73ee93 100644 --- a/helpers/postgresql.py +++ b/helpers/postgresql.py @@ -246,7 +246,7 @@ class Postgresql: cmd = self.callback[cb_name] is_master = self.is_leader() name = self.name - subprocess.Popen(shlex.split(cmd)+[is_master, name]) + subprocess.Popen(shlex.split(os.path.abspath(cmd))+[cb_name, is_master, name]) return True def start(self): From 15f94f42241d46b54cca38376fd1f927c28506e8 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Mon, 13 Jul 2015 11:47:55 +0200 Subject: [PATCH 07/32] Use actual role name and not boolean flag if it is a master to tag AWS objects (EBS volumes and EC2 instances). --- scripts/aws.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/aws.py b/scripts/aws.py index 6a04da92..7930323e 100644 --- a/scripts/aws.py +++ b/scripts/aws.py @@ -74,9 +74,9 @@ if __name__ == '__main__': if len(sys.argv) != 3: print ("Usage: {0} action role name".format(sys.argv[0])) return 1 - action, role, name = sys.argv[1:] + action, is_master, name = sys.argv[1:] if action in ('on_start', 'on_stop', 'on_role_change'): aws = AWSConnection({'cluster_name': name}) - aws.on_role_change(role) + aws.on_role_change('master' if is_master else 'replica') return 0 return 2 From d8aa1ff849989979e8bcfae3ae3b0e1f58237513 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Tue, 14 Jul 2015 09:22:49 +0200 Subject: [PATCH 08/32] fix a typo. --- helpers/postgresql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/postgresql.py b/helpers/postgresql.py index de73ee93..41bc13c8 100644 --- a/helpers/postgresql.py +++ b/helpers/postgresql.py @@ -43,7 +43,7 @@ class Postgresql: self.replication = config['replication'] self.superuser = config['superuser'] self.admin = config['admin'] - self.callback = config.get(['callback'], {}) + self.callback = config.get('callback', {}) self.recovery_conf = os.path.join(self.data_dir, 'recovery.conf') self.configuration_to_save = (os.path.join(self.data_dir, 'pg_hba.conf'), os.path.join(self.data_dir, 'postgresql.conf')) From fcead3aed94bc19e7ab2b5246484ae2670849783 Mon Sep 17 00:00:00 2001 From: Alexander Kukushkin Date: Wed, 15 Jul 2015 15:25:20 +0200 Subject: [PATCH 09/32] Update README.md --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ecbcebb..b894c5d9 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,11 @@ For an example file, see `postgres0.yml`. Below is an explanation of settings: * *scope*: the relative path used on etcd's http api for this deployment, thus you can run multiple HA deployments from a single etcd * *session_timeout*: the TTL to acquire the leader lock. Think of it as the length of time before automatic failover process is initiated. * *reconnects_timeout*: how long we should try to reconnect to ZooKeeper after connection loss. After this timeout we assume that we don't have lock anymore and will restart in read-only mode. - * *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...'] + * *exhibitor*: if you are running ZooKeeper cluster under Exhibitor supervisory the following section could be interesting for you + * *poll_interval*: how often list of ZooKeeper and Exhibitor nodes should be updated from Exhibitor + * *port*: Exhibitor port + * *hosts*: initial list of Exhibitor (ZooKeeper) nodes in format: [ 'host1', 'host2', 'etc...' ]. This list would be updated automatically when Exhibitor (ZooKeeper) cluster topology changes. * *postgresql* * *name*: the name of the Postgres host, must be unique for the cluster From d1e47c8ccfbcb298718be3e645dfb2b3644e9aad Mon Sep 17 00:00:00 2001 From: Alexander Kukushkin Date: Wed, 15 Jul 2015 16:17:38 +0200 Subject: [PATCH 10/32] Update README.md --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index b894c5d9..f90478ff 100644 --- a/README.md +++ b/README.md @@ -67,10 +67,17 @@ For an example file, see `postgres0.yml`. Below is an explanation of settings: * *connect_address*: ip address + port through which Postgres is accessible from other nodes and applications. * *data_dir*: file path to initialize and store Postgres data files * *maximum_lag_on_failover*: the maximum bytes a follower may lag before it is not eligible become leader + * *pg_hba*: list of lines which should be added to pg_hba.conf + * *- host all all 0.0.0.0/0 md5* * *replication* * *username*: replication username, user will be created during initialization * *password*: replication password, user will be created during initialization * *network*: network setting for replication in pg_hba.conf + * *superuser* + * *password*: password for postgres user. It would be set during initialization + * *admin*: + * *username*: admin username, user will be created during initialization. It would have CREATEDB and CREATEROLE privileges + * *password*: admin password, user will be created during initialization. * *recovery_conf*: configuration settings written to recovery.conf when configuring follower * *parameters*: list of configuration settings for Postgres From 11004f8c48062ebd3dea0bccda8985975e0217b7 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Fri, 17 Jul 2015 11:19:05 +0200 Subject: [PATCH 11/32] Be more robust on callbacks when cluster is down. For 'on_stop' or 'on_restart' callbacks we cannot call is_leader, because the cluster would be stopped. Instead, call is_leader before running a callback. In addition, make the call_nowait more robust by handling the exceptions related to the DB cluster unavailability. --- helpers/postgresql.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/helpers/postgresql.py b/helpers/postgresql.py index 41bc13c8..4d72fcec 100644 --- a/helpers/postgresql.py +++ b/helpers/postgresql.py @@ -239,14 +239,19 @@ class Postgresql: def is_running(self): return subprocess.call(' '.join(self._pg_ctl) + ' status > /dev/null', shell=True) == 0 - def call_nowait(self, cb_name): + def call_nowait(self, cb_name, is_leader=None): """ pick a callback command and call it without waiting for it to finish """ if not self.callback or cb_name not in self.callback: return False cmd = self.callback[cb_name] - is_master = self.is_leader() + if not is_leader: + try: + is_leader = self.is_leader() + except psycopg2.OperationalError as e: + logger.warning("unable to perform {0} action, cannot obtain the cluster role: {1}".format(cb_name, e)) + return False name = self.name - subprocess.Popen(shlex.split(os.path.abspath(cmd))+[cb_name, is_master, name]) + subprocess.Popen(shlex.split(os.path.abspath(cmd))+[cb_name, is_leader, name]) return True def start(self): @@ -267,9 +272,14 @@ class Postgresql: return ret def stop(self): + try: + is_leader = self.is_leader() + except: + is_leader = None + pass ret = subprocess.call(self._pg_ctl + ['stop', '-m', 'fast']) if ret == 0 and 'on_stop' in self.callback: - self.call_nowait('on_stop') + self.call_nowait('on_stop', is_leader=is_leader) return ret != 0 def reload(self): @@ -279,9 +289,14 @@ class Postgresql: return ret == 0 def restart(self): + try: + is_leader = self.is_leader() + except: + is_leader = None + pass ret = subprocess.call(self._pg_ctl + ['restart', '-m', 'fast']) if ret == 0 and 'on_restart' in self.callback: - self.call_nowait('on_restart') + self.call_nowait('on_restart', is_leader=is_leader) return ret == 0 def server_options(self): From 33b5e1b22e95a66f55819defc443ab4fa14f502f Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Fri, 17 Jul 2015 15:22:21 +0200 Subject: [PATCH 12/32] Make sure is_leader is False does not trigger the leader re-check. --- helpers/postgresql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/postgresql.py b/helpers/postgresql.py index 4d72fcec..3fcb44da 100644 --- a/helpers/postgresql.py +++ b/helpers/postgresql.py @@ -244,7 +244,7 @@ class Postgresql: if not self.callback or cb_name not in self.callback: return False cmd = self.callback[cb_name] - if not is_leader: + if is_leader is None: try: is_leader = self.is_leader() except psycopg2.OperationalError as e: From a11d94aff53d00595e0e8c6c598a5af84a48d0e5 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Fri, 17 Jul 2015 15:39:34 +0200 Subject: [PATCH 13/32] Pass text names of cluster roles to the script. Seems better than passing a bool flag and converting it from/to string. --- helpers/postgresql.py | 2 +- scripts/aws.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/helpers/postgresql.py b/helpers/postgresql.py index 3fcb44da..371c187a 100644 --- a/helpers/postgresql.py +++ b/helpers/postgresql.py @@ -251,7 +251,7 @@ class Postgresql: logger.warning("unable to perform {0} action, cannot obtain the cluster role: {1}".format(cb_name, e)) return False name = self.name - subprocess.Popen(shlex.split(os.path.abspath(cmd))+[cb_name, is_leader, name]) + subprocess.Popen(shlex.split(os.path.abspath(cmd))+[cb_name, "master" if is_leader else "replica", name]) return True def start(self): diff --git a/scripts/aws.py b/scripts/aws.py index 7930323e..6a04da92 100644 --- a/scripts/aws.py +++ b/scripts/aws.py @@ -74,9 +74,9 @@ if __name__ == '__main__': if len(sys.argv) != 3: print ("Usage: {0} action role name".format(sys.argv[0])) return 1 - action, is_master, name = sys.argv[1:] + action, role, name = sys.argv[1:] if action in ('on_start', 'on_stop', 'on_role_change'): aws = AWSConnection({'cluster_name': name}) - aws.on_role_change('master' if is_master else 'replica') + aws.on_role_change(role) return 0 return 2 From e9934be9aa80f04990eb07eb6708c50422cff4aa Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Fri, 17 Jul 2015 17:26:28 +0200 Subject: [PATCH 14/32] Do not terminate on callback failure. Show the error if a callback command fails and move on. Set the executable bit for the aws callback. --- helpers/postgresql.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/helpers/postgresql.py b/helpers/postgresql.py index 371c187a..0c31ddac 100644 --- a/helpers/postgresql.py +++ b/helpers/postgresql.py @@ -251,7 +251,12 @@ class Postgresql: logger.warning("unable to perform {0} action, cannot obtain the cluster role: {1}".format(cb_name, e)) return False name = self.name - subprocess.Popen(shlex.split(os.path.abspath(cmd))+[cb_name, "master" if is_leader else "replica", name]) + try: + role = "master" if is_leader else "replica" + subprocess.Popen(shlex.split(os.path.abspath(cmd))+[cb_name, role, name]) + except Exception as e: + logger.warning("callback {0} {1} {2} {3} failed: {4}".format(os.path.abspath(cmd), cb_name, role, name, e)) + return False return True def start(self): From f1d76611b6b7c2f1b1a15c72976e5c1029f3b4a8 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Fri, 17 Jul 2015 17:26:52 +0200 Subject: [PATCH 15/32] Use the executable bit on AWS callback. --- scripts/aws.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/aws.py diff --git a/scripts/aws.py b/scripts/aws.py old mode 100644 new mode 100755 From c9c678fb5b8cbd80b806302d5d63159d1c31f922 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Thu, 23 Jul 2015 16:16:22 +0200 Subject: [PATCH 16/32] Pass scope, the cluster name, instead of the name, which is the instance name, to the callback. --- helpers/postgresql.py | 3 ++- scripts/aws.py | 10 ++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) mode change 100755 => 100644 scripts/aws.py diff --git a/helpers/postgresql.py b/helpers/postgresql.py index 0c31ddac..6348c3ff 100644 --- a/helpers/postgresql.py +++ b/helpers/postgresql.py @@ -38,6 +38,7 @@ class Postgresql: def __init__(self, config): self.config = config self.name = config['name'] + self.scope = config['scope'] self.listen_addresses, self.port = config['listen'].split(':') self.data_dir = config['data_dir'] self.replication = config['replication'] @@ -250,7 +251,7 @@ class Postgresql: except psycopg2.OperationalError as e: logger.warning("unable to perform {0} action, cannot obtain the cluster role: {1}".format(cb_name, e)) return False - name = self.name + name = self.scope try: role = "master" if is_leader else "replica" subprocess.Popen(shlex.split(os.path.abspath(cmd))+[cb_name, role, name]) diff --git a/scripts/aws.py b/scripts/aws.py old mode 100755 new mode 100644 index 6a04da92..0025e44e --- a/scripts/aws.py +++ b/scripts/aws.py @@ -1,3 +1,5 @@ +#!/usr/bin/python + import logging import requests from requests.exceptions import RequestException @@ -71,12 +73,12 @@ class AWSConnection: if __name__ == '__main__': - if len(sys.argv) != 3: + if len(sys.argv) != 4: print ("Usage: {0} action role name".format(sys.argv[0])) - return 1 + sys.exit(1) action, role, name = sys.argv[1:] if action in ('on_start', 'on_stop', 'on_role_change'): aws = AWSConnection({'cluster_name': name}) aws.on_role_change(role) - return 0 - return 2 + sys.exit(0) + sys.exit(2) From 35fe536e8b6ff2a073682592243744c7d1b36f4a Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Thu, 23 Jul 2015 17:09:39 +0200 Subject: [PATCH 17/32] Set +x bit on aws.py --- scripts/aws.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/aws.py diff --git a/scripts/aws.py b/scripts/aws.py old mode 100644 new mode 100755 From dac3d7997fdb2eb9994a0dfe2de543cb9de1c17b Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Fri, 24 Jul 2015 10:47:26 +0200 Subject: [PATCH 18/32] Change a typo. --- helpers/postgresql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/postgresql.py b/helpers/postgresql.py index 6348c3ff..a5ae8b36 100644 --- a/helpers/postgresql.py +++ b/helpers/postgresql.py @@ -44,7 +44,7 @@ class Postgresql: self.replication = config['replication'] self.superuser = config['superuser'] self.admin = config['admin'] - self.callback = config.get('callback', {}) + self.callback = config.get('callbacks', {}) self.recovery_conf = os.path.join(self.data_dir, 'recovery.conf') self.configuration_to_save = (os.path.join(self.data_dir, 'pg_hba.conf'), os.path.join(self.data_dir, 'postgresql.conf')) From 859da0db0adb98505ad840afd0cd4f7527399554 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Mon, 27 Jul 2015 10:10:29 +0200 Subject: [PATCH 19/32] Make unit tets run again. --- postgres0.yml | 6 ++++-- postgres1.yml | 6 ++++-- tests/test_aws.py | 32 +++++++++++++++++++++++++------- tests/test_postgresql.py | 10 +++++----- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/postgres0.yml b/postgres0.yml index 0cb694a1..6c9fb82d 100644 --- a/postgres0.yml +++ b/postgres0.yml @@ -1,20 +1,22 @@ ttl: &ttl 30 loop_wait: &loop_wait 10 +scope: &scope batman restapi: listen: 127.0.0.1:8008 connect_address: 127.0.0.1:8008 etcd: - scope: batman + scope: *scope ttl: *ttl host: 127.0.0.1:4001 #discovery_srv: my-etcd.domain #zookeeper: -# scope: batman +# scope: *scope # session_timeout: *ttl # reconnect_timeout: *loop_wait # hosts: 127.0.0.1:2181 postgresql: name: postgresql0 + scope: *scope listen: 127.0.0.1:5432 connect_address: 127.0.0.1:5432 data_dir: data/postgresql0 diff --git a/postgres1.yml b/postgres1.yml index 680091f1..1928cee7 100644 --- a/postgres1.yml +++ b/postgres1.yml @@ -1,20 +1,22 @@ ttl: &ttl 30 loop_wait: &loop_wait 10 +scope: &scope batman restapi: listen: 127.0.0.1:8009 connect_address: 127.0.0.1:8009 etcd: - scope: batman + scope: *scope ttl: *ttl host: 127.0.0.1:4001 #discovery_srv: my-etcd.domain #zookeeper: -# scope: batman +# scope: *scope # session_timeout: *ttl # reconnect_timeout: *loop_wait # hosts: 127.0.0.1:2181 postgresql: name: postgresql1 + scope: *scope listen: 127.0.0.1:5433 connect_address: 127.0.0.1:5433 data_dir: data/postgresql1 diff --git a/tests/test_aws.py b/tests/test_aws.py index 63b50734..4331badb 100644 --- a/tests/test_aws.py +++ b/tests/test_aws.py @@ -2,7 +2,7 @@ import unittest import requests import boto.ec2 from collections import namedtuple -from helpers.aws import AWSConnection +from scripts.aws import AWSConnection from requests.exceptions import RequestException import yaml @@ -24,6 +24,16 @@ class MockEc2Connection: return True +class MockResponse: + + def __init__(self, content): + self.content = content + self.ok = True + + def json(self): + return self.content + + class TestAWSConnection(unittest.TestCase): def __init__(self, method_name='runTest'): @@ -44,25 +54,28 @@ class TestAWSConnection(unittest.TestCase): result = namedtuple('Request', 'ok content') result.ok = True if url.split('/')[-1] == 'document': - result.content = '{\n "instanceId" : "012345",\n "region" : "eu-west-1"\n}' + result = {"instanceId": "012345", "region": "eu-west-1"} else: - result.content = 'foo' - return result + result = 'foo' + return MockResponse(result) def setUp(self): self.error = False requests.get = self.requests_get boto.ec2.connect_to_region = self.boto_ec2_connect_to_region self.config_string = """ -loop_wait: 10 +scope: &scope test +ttl: &ttl 30 +loop_wait: &loop_wait 10 restapi: listen: 0.0.0.0:8008 connect_address: 127.0.0.1:5432 etcd: - scope: test - ttl: 30 + scope: *scope + ttl: *ttl host: 127.0.0.1:8080 postgresql: + scope: *scope name: postgresql_foo listen: 0.0.0.0:5432 connect_address: 127.0.0.1:5432 @@ -76,6 +89,11 @@ postgresql: admin: username: admin password: admin + callbacks: + on_start: patroni/scripts/aws.py + on_stop: patroni/scripts/aws.py + on_restart: patroni/scripts/aws.py + on_role_change: patroni/scripts/aws.py parameters: archive_mode: "on" wal_level: hot_standby diff --git a/tests/test_postgresql.py b/tests/test_postgresql.py index d6c01f5b..069c4622 100644 --- a/tests/test_postgresql.py +++ b/tests/test_postgresql.py @@ -110,15 +110,15 @@ class TestPostgresql(unittest.TestCase): def set_up(self): subprocess.call = subprocess_call shutil.copy = nop - self.p = Postgresql({'name': 'test0', 'data_dir': 'data/test0', 'listen': '127.0.0.1, *:5432', - 'connect_address': '127.0.0.2:5432', + self.p = Postgresql({'name': 'test0', 'scope': 'batman', 'data_dir': 'data/test0', + 'listen': '127.0.0.1, *:5432', 'connect_address': '127.0.0.2:5432', 'pg_hba': ['hostssl all all 0.0.0.0/0 md5', 'host all all 0.0.0.0/0 md5'], - 'superuser': {'password': ''}, 'admin': {'username': 'admin', 'password': 'admin'}, + 'superuser': {'password': ''}, + 'admin': {'username': 'admin', 'password': 'admin'}, 'replication': {'username': 'replicator', 'password': 'rep-pass', 'network': '127.0.0.1/32'}, - 'parameters': {'foo': 'bar'}, 'recovery_conf': {'foo': 'bar'}}, - on_change_callback=lambda state: True) + 'parameters': {'foo': 'bar'}, 'recovery_conf': {'foo': 'bar'}}) psycopg2.connect = psycopg2_connect if not os.path.exists(self.p.data_dir): os.makedirs(self.p.data_dir) From 201f9bf4b29e3bd6cb9c9ccb4a25e62663d04b7a Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Mon, 27 Jul 2015 11:08:19 +0200 Subject: [PATCH 20/32] Make sure role check during callbacks does not accidentially modify the cluster state (is_promoted flag or trigger file). --- helpers/postgresql.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/helpers/postgresql.py b/helpers/postgresql.py index a5ae8b36..71eb7d9c 100644 --- a/helpers/postgresql.py +++ b/helpers/postgresql.py @@ -230,9 +230,9 @@ class Postgresql: return (diff_in_bytes < long(threshold_megabytes) * 1048576) and\ (diff_in_bytes < long(backup_size) * float(threshold_backup_size_percentage) / 100) - def is_leader(self): + def is_leader(self, check_only=False): ret = not self.query('SELECT pg_is_in_recovery()').fetchone()[0] - if ret and self.is_promoted: + if ret and self.is_promoted and not check_only: self.delete_trigger_file() self.is_promoted = False return ret @@ -247,7 +247,7 @@ class Postgresql: cmd = self.callback[cb_name] if is_leader is None: try: - is_leader = self.is_leader() + is_leader = self.is_leader(check_only=True) except psycopg2.OperationalError as e: logger.warning("unable to perform {0} action, cannot obtain the cluster role: {1}".format(cb_name, e)) return False @@ -279,7 +279,7 @@ class Postgresql: def stop(self): try: - is_leader = self.is_leader() + is_leader = self.is_leader(check_only=True) except: is_leader = None pass @@ -296,7 +296,7 @@ class Postgresql: def restart(self): try: - is_leader = self.is_leader() + is_leader = self.is_leader(check_only=True) except: is_leader = None pass From 115ba1d9ae2cf4c87526d5fcb2ccc92b58414fde Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Mon, 27 Jul 2015 11:23:08 +0200 Subject: [PATCH 21/32] Make sure postgresql.stop also return True if pg_ctl stop returned 0. --- helpers/postgresql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/postgresql.py b/helpers/postgresql.py index 71eb7d9c..da2d1fdf 100644 --- a/helpers/postgresql.py +++ b/helpers/postgresql.py @@ -286,7 +286,7 @@ class Postgresql: ret = subprocess.call(self._pg_ctl + ['stop', '-m', 'fast']) if ret == 0 and 'on_stop' in self.callback: self.call_nowait('on_stop', is_leader=is_leader) - return ret != 0 + return ret == 0 def reload(self): ret = subprocess.call(self._pg_ctl + ['reload']) From 56983d867db449c218af97766043b8d9966fb079 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Mon, 27 Jul 2015 13:03:12 +0200 Subject: [PATCH 22/32] Run coverage test for the data in scripts as well as helpers. --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5ac4a89f..db327c37 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,7 @@ __location__ = os.path.join(os.getcwd(), os.path.dirname(inspect.getfile(inspect NAME = 'patroni' MAIN_PACKAGE = 'patroni.py' HELPERS = 'helpers' +SCRIPTS = 'scripts' VERSION = '0.1' DESCRIPTION = 'A Template for PostgreSQL HA with etcd' LICENSE = 'The MIT License' @@ -61,7 +62,8 @@ class PyTest(TestCommand): def finalize_options(self): TestCommand.finalize_options(self) if self.cov_xml or self.cov_html: - self.cov = ['--cov', MAIN_PACKAGE, '--cov', HELPERS, '--cov-report', 'term-missing'] + self.cov = ['--cov', MAIN_PACKAGE, '--cov', HELPERS, '--cov', SCRIPTS, '--cov-report', + 'term-missing'] if self.cov_xml: self.cov.extend(['--cov-report', 'xml']) if self.cov_html: From b92bfb34a59555523ca66172e908da697b3c961f Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Mon, 27 Jul 2015 13:04:01 +0200 Subject: [PATCH 23/32] Futher test for postgresql module (includes callbacks) --- tests/test_postgresql.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/test_postgresql.py b/tests/test_postgresql.py index 069c4622..c93a7a3c 100644 --- a/tests/test_postgresql.py +++ b/tests/test_postgresql.py @@ -118,7 +118,11 @@ class TestPostgresql(unittest.TestCase): 'replication': {'username': 'replicator', 'password': 'rep-pass', 'network': '127.0.0.1/32'}, - 'parameters': {'foo': 'bar'}, 'recovery_conf': {'foo': 'bar'}}) + 'parameters': {'foo': 'bar'}, 'recovery_conf': {'foo': 'bar'}, + 'callbacks': {'on_start': '/usr/bin/true', 'on_stop': '/usr/bin/true', + 'on_restart': '/usr/bin/true', 'on_role_change': '/bin/true', + 'on_reload': '/usr/bin/true' + }}) psycopg2.connect = psycopg2_connect if not os.path.exists(self.p.data_dir): os.makedirs(self.p.data_dir) @@ -129,6 +133,9 @@ class TestPostgresql(unittest.TestCase): def tear_down(self): shutil.rmtree('data') + def mock_query(self, p): + raise psycopg2.OperationalError("not supported") + def test_data_directory_empty(self): self.assertTrue(self.p.data_directory_empty()) @@ -136,12 +143,13 @@ class TestPostgresql(unittest.TestCase): self.assertTrue(self.p.initialize()) self.assertTrue(os.path.exists(os.path.join(self.p.data_dir, 'pg_hba.conf'))) - def test_start(self): + def test_start_stop(self): self.assertFalse(self.p.start()) self.p.is_running = is_running with open(os.path.join(self.p.data_dir, 'postmaster.pid'), 'w'): pass self.assertTrue(self.p.start()) + self.assertTrue(self.p.stop()) def test_sync_from_leader(self): self.assertTrue(self.p.sync_from_leader(self.leader)) @@ -196,3 +204,11 @@ class TestPostgresql(unittest.TestCase): def test_last_operation(self): self.assertEquals(self.p.last_operation(), '0') + + def test_non_existing_callback(self): + self.assertFalse(self.p.call_nowait('foobar')) + + def test_is_leader_exception(self): + self.p.start() + self.p.query = self.mock_query + self.assertTrue(self.p.stop()) From d85451efae29d4d7a4e10ff36aa557d2f22556b5 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Mon, 27 Jul 2015 13:04:45 +0200 Subject: [PATCH 24/32] Increase test coverage for aws script. --- scripts/aws.py | 24 +++++--------------- tests/test_aws.py | 58 +++++++++-------------------------------------- 2 files changed, 17 insertions(+), 65 deletions(-) diff --git a/scripts/aws.py b/scripts/aws.py index 0025e44e..0d10701c 100755 --- a/scripts/aws.py +++ b/scripts/aws.py @@ -10,16 +10,9 @@ logger = logging.getLogger(__name__) class AWSConnection: - def __init__(self, config): + def __init__(self, cluster_name): self.available = False - self.config = config - - if 'cluster_name' in config: - self.cluster_name = config.get('cluster_name') - elif 'etcd' in config and isinstance(config['etcd'], dict): - self.cluster_name = config['etcd'].get('scope', 'unknown') - else: - self.cluster_name = 'unknown' + self.cluster_name = cluster_name if cluster_name is not None else 'unknown' try: # get the instance id r = requests.get('http://169.254.169.254/latest/dynamic/instance-identity/document', timeout=0.1) @@ -73,12 +66,7 @@ class AWSConnection: if __name__ == '__main__': - if len(sys.argv) != 4: - print ("Usage: {0} action role name".format(sys.argv[0])) - sys.exit(1) - action, role, name = sys.argv[1:] - if action in ('on_start', 'on_stop', 'on_role_change'): - aws = AWSConnection({'cluster_name': name}) - aws.on_role_change(role) - sys.exit(0) - sys.exit(2) + if len(sys.argv) != 4 and sys.argv[1] in ('on_start', 'on_stop', 'on_role_change'): + AWSConnection(cluster_name=sys.argv[3]).on_role_change(sys.argv[2]) + else: + sys.exit("Usage: {0} action role name".format(sys.argv[0])) diff --git a/tests/test_aws.py b/tests/test_aws.py index 4331badb..84d495fe 100644 --- a/tests/test_aws.py +++ b/tests/test_aws.py @@ -4,7 +4,6 @@ import boto.ec2 from collections import namedtuple from scripts.aws import AWSConnection from requests.exceptions import RequestException -import yaml class MockEc2Connection: @@ -42,8 +41,8 @@ class TestAWSConnection(unittest.TestCase): def set_error(self): self.error = True - def set_ok(self): - self.error = False + def set_json_error(self): + self.json_error = True def boto_ec2_connect_to_region(self, region): return MockEc2Connection(self.error) @@ -53,7 +52,7 @@ class TestAWSConnection(unittest.TestCase): raise RequestException("foo") result = namedtuple('Request', 'ok content') result.ok = True - if url.split('/')[-1] == 'document': + if url.split('/')[-1] == 'document' and not self.json_error: result = {"instanceId": "012345", "region": "eu-west-1"} else: result = 'foo' @@ -61,50 +60,10 @@ class TestAWSConnection(unittest.TestCase): def setUp(self): self.error = False + self.json_error = False requests.get = self.requests_get boto.ec2.connect_to_region = self.boto_ec2_connect_to_region - self.config_string = """ -scope: &scope test -ttl: &ttl 30 -loop_wait: &loop_wait 10 -restapi: - listen: 0.0.0.0:8008 - connect_address: 127.0.0.1:5432 -etcd: - scope: *scope - ttl: *ttl - host: 127.0.0.1:8080 -postgresql: - scope: *scope - name: postgresql_foo - listen: 0.0.0.0:5432 - connect_address: 127.0.0.1:5432 - data_dir: /home/postgres/pgdata/data - replication: - username: standby - password: standby - network: 0.0.0.0/0 - superuser: - password: zalando - admin: - username: admin - password: admin - callbacks: - on_start: patroni/scripts/aws.py - on_stop: patroni/scripts/aws.py - on_restart: patroni/scripts/aws.py - on_role_change: patroni/scripts/aws.py - parameters: - archive_mode: "on" - wal_level: hot_standby - max_wal_senders: 5 - wal_keep_segments: 8 - archive_timeout: 1800s - max_replication_slots: 5 - hot_standby: "on" - ssl: "on" -""" - self.conn = AWSConnection(yaml.load(self.config_string)) + self.conn = AWSConnection('test') def test_aws_available(self): self.assertTrue(self.conn.aws_available()) @@ -116,11 +75,16 @@ postgresql: def test_non_aws(self): self.set_error() - conn = AWSConnection(yaml.load(self.config_string)) + conn = AWSConnection('test') self.assertFalse(conn.aws_available()) self.assertFalse(conn._tag_ebs('master')) self.assertFalse(conn._tag_ec2('master')) + def test_aws_bizare_response(self): + self.set_json_error() + conn = AWSConnection('test') + self.assertFalse(conn.aws_available()) + def test_aws_tag_ebs_error(self): self.set_error() self.assertFalse(self.conn._tag_ebs("master")) From 7d1bc32ad93d70b1d4814fc0b5bc2badb2aa1130 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Mon, 27 Jul 2015 13:06:44 +0200 Subject: [PATCH 25/32] potentially run the doctests for the scripts. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index db327c37..62d05a69 100644 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ class PyTest(TestCommand): params['plugins'] = ['cov'] if self.junitxml: params['args'] += self.junitxml - params['args'] += ['--doctest-modules', HELPERS, '-s'] + params['args'] += ['--doctest-modules', HELPERS, '--doctest-modules', SCRIPTS, '-s'] errno = pytest.main(**params) sys.exit(errno) From 27bda5b4e46237f800bddb373a29b3366fb3eeeb Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Mon, 27 Jul 2015 13:12:08 +0200 Subject: [PATCH 26/32] document the new callbacks option. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 3ecbcebb..be12dc71 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ For an example file, see `postgres0.yml`. Below is an explanation of settings: * *username*: replication username, user will be created during initialization * *password*: replication password, user will be created during initialization * *network*: network setting for replication in pg_hba.conf + * *callbacks* callback scripts to run on certain actions. Patroni will pass current action, role and cluster name. See scripts/aws.py as an example on how to write them. + * *on_start*: a script to run when the cluster starts + * *on_stop*: a script to run when the cluster stops + * *on_restart*: a script to run when the cluster restarts + * *on_reload*: a script to run when configuration reload is triggered + * *on_role_change*: a script to run when the cluster is being promoted or demoted * *recovery_conf*: configuration settings written to recovery.conf when configuring follower * *parameters*: list of configuration settings for Postgres From 5c5bae69eaa8b03f893375038424b4db32fac397 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Mon, 27 Jul 2015 16:11:58 +0200 Subject: [PATCH 27/32] make sure we can import from scripts. --- scripts/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 scripts/__init__.py diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 00000000..e69de29b From 71a1200f64cbe60fe2123e19ea1a9035f97615e0 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Wed, 29 Jul 2015 12:09:17 +0200 Subject: [PATCH 28/32] Fix a mistake in checking for the number of parameters. --- scripts/aws.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/aws.py b/scripts/aws.py index b5e87fd7..b172a8c3 100755 --- a/scripts/aws.py +++ b/scripts/aws.py @@ -66,7 +66,7 @@ class AWSConnection: if __name__ == '__main__': - if len(sys.argv) != 4 and sys.argv[1] in ('on_start', 'on_stop', 'on_role_change'): + if len(sys.argv) == 4 and sys.argv[1] in ('on_start', 'on_stop', 'on_role_change'): AWSConnection(cluster_name=sys.argv[3]).on_role_change(sys.argv[2]) else: sys.exit("Usage: {0} action role name".format(sys.argv[0])) From a0fdd6398f0afcf5d29abd89101e9ee3da949b87 Mon Sep 17 00:00:00 2001 From: Oleksii Kliukin Date: Thu, 30 Jul 2015 10:17:17 +0200 Subject: [PATCH 29/32] Cosmetic changes: avoid magic strings in action names, name the parameter passed to the callback 'scope' for consistency. --- helpers/postgresql.py | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/helpers/postgresql.py b/helpers/postgresql.py index 325d7080..84a94346 100644 --- a/helpers/postgresql.py +++ b/helpers/postgresql.py @@ -16,6 +16,12 @@ else: logger = logging.getLogger(__name__) +ACTION_ON_START = "on_start" +ACTION_ON_STOP = "on_stop" +ACTION_ON_RESTART = "on_restart" +ACTION_ON_RELOAD = "on_reload" +ACTION_ON_ROLE_CHANGE = "on_role_change" + def parseurl(url): r = urlparse(url) @@ -253,12 +259,12 @@ class Postgresql: except psycopg2.OperationalError as e: logger.warning("unable to perform {0} action, cannot obtain the cluster role: {1}".format(cb_name, e)) return False - name = self.scope + scope = self.scope try: role = "master" if is_leader else "replica" - subprocess.Popen(shlex.split(os.path.abspath(cmd))+[cb_name, role, name]) + subprocess.Popen(shlex.split(os.path.abspath(cmd))+[cb_name, role, scope]) except Exception as e: - logger.warning("callback {0} {1} {2} {3} failed: {4}".format(os.path.abspath(cmd), cb_name, role, name, e)) + logger.warning("callback {0} {1} {2} {3} failed: {4}".format(os.path.abspath(cmd), cb_name, role, scope, e)) return False return True @@ -275,8 +281,8 @@ class Postgresql: ret = subprocess.call(self._pg_ctl + ['start', '-o', self.server_options()]) == 0 ret and self.load_replication_slots() self.save_configuration_files() - if ret and 'on_start' in self.callback: - self.call_nowait('on_start') + if ret and ACTION_ON_START in self.callback: + self.call_nowait(ACTION_ON_START) return ret def stop(self): @@ -286,14 +292,14 @@ class Postgresql: is_leader = None pass ret = subprocess.call(self._pg_ctl + ['stop', '-m', 'fast']) - if ret == 0 and 'on_stop' in self.callback: - self.call_nowait('on_stop', is_leader=is_leader) + if ret == 0 and ACTION_ON_STOP in self.callback: + self.call_nowait(ACTION_ON_STOP, is_leader=is_leader) return ret == 0 def reload(self): ret = subprocess.call(self._pg_ctl + ['reload']) - if ret == 0 and 'on_reload' in self.callback: - self.call_nowait('on_reload') + if ret == 0 and ACTION_ON_RELOAD in self.callback: + self.call_nowait(ACTION_ON_RELOAD) return ret == 0 def restart(self): @@ -303,8 +309,8 @@ class Postgresql: is_leader = None pass ret = subprocess.call(self._pg_ctl + ['restart', '-m', 'fast']) - if ret == 0 and 'on_restart' in self.callback: - self.call_nowait('on_restart', is_leader=is_leader) + if ret == 0 and ACTION_ON_RESTART in self.callback: + self.call_nowait(ACTION_ON_RESTART, is_leader=is_leader) return ret == 0 def server_options(self): @@ -392,8 +398,8 @@ primary_conninfo = '{}' if not self.check_recovery_conf(leader): self.write_recovery_conf(leader) self.restart() - if 'on_role_change' in self.callback: - self.call_nowait('on_role_change') + if ACTION_ON_ROLE_CHANGE in self.callback: + self.call_nowait(ACTION_ON_ROLE_CHANGE) def save_configuration_files(self): """ @@ -413,8 +419,8 @@ primary_conninfo = '{}' def promote(self): self.is_promoted = subprocess.call(self._pg_ctl + ['promote']) == 0 - if self.is_promoted and 'on_role_change' in self.callback: - self.call_nowait('on_role_change') + if self.is_promoted and ACTION_ON_ROLE_CHANGE in self.callback: + self.call_nowait(ACTION_ON_ROLE_CHANGE) return self.is_promoted def demote(self, leader): From 7c4efb33e74a2b715b964011566ae2e7a7d0c6ad Mon Sep 17 00:00:00 2001 From: Feike Steenbergen Date: Wed, 5 Aug 2015 09:56:32 +0200 Subject: [PATCH 30/32] Updated Dockerfile Due to the renaming of Governor to Patroni some old references needed to be updated. Also some python packages need to be added. Added entrypoint.sh as a script, to ensure Patroni will have PID = 1 when the container is run. --- Dockerfile | 19 ++++++++++--------- entrypoint.sh | 3 +++ 2 files changed, 13 insertions(+), 9 deletions(-) create mode 100755 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index d44a9af3..d8e27c2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -## This Dockerfile is meant to aid in the building and debugging governor 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 FROM ubuntu:14.04 MAINTAINER Feike Steenbergen @@ -13,22 +13,23 @@ RUN apt-get update -y RUN apt-get upgrade -y ENV PGVERSION 9.4 -RUN apt-get install python python-psycopg2 python-yaml python-requests python-boto postgresql-${PGVERSION} python-dnspython -y +RUN apt-get install python python-psycopg2 python-yaml python-requests python-boto postgresql-${PGVERSION} python-dnspython python-pip -y +RUN pip install zake ENV PATH /usr/lib/postgresql/${PGVERSION}/bin:$PATH -RUN mkdir -p /governor/helpers -ADD governor.py /governor/governor.py -ADD helpers /governor/helpers -ADD postgres0.yml /governor/ +RUN mkdir -p /patroni/helpers +ADD patroni.py /patroni/patroni.py +ADD helpers /patroni/helpers +ADD postgres0.yml /patroni/ ENV ETCDVERSION 2.0.12 RUN curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz | tar xz -C /bin --strip=1 --wildcards --no-anchored etcd etcdctl ## Setting up a simple script that will serve as an entrypoint RUN mkdir /data/ && touch /var/log/etcd.log /var/log/etcd.err && chown postgres:postgres /var/log/etcd.* -RUN chown postgres:postgres -R /governor/ /data/ -RUN /bin/echo -e "etcd --data-dir /tmp/etcd.data > /var/log/etcd.log 2> /var/log/etcd.err &\n/governor/governor.py /governor/postgres0.yml \"\$@\"" >> /entrypoint.sh && chmod +x /entrypoint.sh +RUN chown postgres:postgres -R /patroni/ /data/ +ADD entrypoint.sh /entrypoint.sh -ENTRYPOINT /entrypoint.sh +ENTRYPOINT ["/bin/bash", "/entrypoint.sh"] USER postgres diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 00000000..ab76a0dd --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/bash +etcd --data-dir /tmp/etcd.data > /var/log/etcd.log 2> /var/log/etcd.err & +exec /patroni/patroni.py /patroni/postgres0.yml "$@" From c30d8dbd1ae278795e36d680999e8f8d0b1f4e4a Mon Sep 17 00:00:00 2001 From: Feike Steenbergen Date: Wed, 5 Aug 2015 14:12:45 +0200 Subject: [PATCH 31/32] Development: Update Dockerfile and create possibility to run local cluster To help in developing features, the Dockerfile and its entrypoint have been extended. The README.md explains stuff in detail, in short: - you can now run a Patroni cluster with a single command --- Dockerfile | 14 ++-- docker/README.md | 46 +++++++++++++ docker/dev_patroni_cluster.sh | 90 +++++++++++++++++++++++++ docker/entrypoint.sh | 122 ++++++++++++++++++++++++++++++++++ entrypoint.sh | 3 - 5 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 docker/README.md create mode 100755 docker/dev_patroni_cluster.sh create mode 100755 docker/entrypoint.sh delete mode 100755 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index d8e27c2d..147cad38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,23 +13,23 @@ RUN apt-get update -y RUN apt-get upgrade -y ENV PGVERSION 9.4 -RUN apt-get install python python-psycopg2 python-yaml python-requests python-boto postgresql-${PGVERSION} python-dnspython python-pip -y -RUN pip install zake +RUN apt-get install python python-psycopg2 python-yaml python-requests python-boto postgresql-${PGVERSION} python-dnspython python-kazoo -y ENV PATH /usr/lib/postgresql/${PGVERSION}/bin:$PATH RUN mkdir -p /patroni/helpers ADD patroni.py /patroni/patroni.py ADD helpers /patroni/helpers -ADD postgres0.yml /patroni/ -ENV ETCDVERSION 2.0.12 +ENV ETCDVERSION 2.0.13 RUN curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz | tar xz -C /bin --strip=1 --wildcards --no-anchored etcd etcdctl ## Setting up a simple script that will serve as an entrypoint -RUN mkdir /data/ && touch /var/log/etcd.log /var/log/etcd.err && chown postgres:postgres /var/log/etcd.* -RUN chown postgres:postgres -R /patroni/ /data/ -ADD entrypoint.sh /entrypoint.sh +RUN mkdir /data/ && touch /var/log/etcd.log /var/log/etcd.err /pgpass /patroni/postgres.yml +RUN chown postgres:postgres -R /patroni/ /data/ /pgpass /var/log/etcd.* /patroni/postgres.yml +ADD docker/entrypoint.sh /entrypoint.sh + +EXPOSE 4001 5432 2380 ENTRYPOINT ["/bin/bash", "/entrypoint.sh"] USER postgres diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..e6245adc --- /dev/null +++ b/docker/README.md @@ -0,0 +1,46 @@ +# Patroni Dockerfile +You can run Patroni in a docker container using this Dockerfile, or by using the Docker image at + https://os-registry.stups.zalan.do/acid/patroni-1.0 + +This Dockerfile is meant in aiding development of Patroni and quick testing of features. It is not a production-worthy +Dockerfile + +# Examples + +## Standalone Patroni + + docker run -d os-registry.stups.zalan.do/acid/patroni:1.0 + +## Multiple Patroni's communicating with a standalone etcd inside Docker + +Basically what you would do would be: + +* Run 1 container which provides etcd + + docker run -d --etcd-only + +* Run n containers running Patroni, passing the `--etcd` option to the `docker run` command + + docker run -d --etcd= + +To automate this you can run the following script: + + dev_patroni_cluster.sh [OPTIONS] + + Options: + + --image IMAGE The Docker image to use for the cluster + --members INT The number of members for the cluster + --name NAME The name of the new cluster + +Example session: + + $ ./dev_patroni_cluster.sh --image os-registry.stups.zalan.do/acid/patroni:1.0 --members=2 --name=bravo + The etcd container is 6be871a11cb373406ca5ea1c6b39e140fdde9fb1d6177212d6ad0c0d1bd9b563, ip=172.17.1.24 + Started Patroni container 67e611f2eca7c40f9e6e0e24a4a8f2cba7e3e56d22a420e15ab9240a37a9d7a4, ip=172.17.1.25 + Started Patroni container 47dd12ae635ab83b039f5889e250048b606ed5e48e3650b69e365e7e1d4acbcf, ip=172.17.1.26 + $ docker ps + CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES + 47dd12ae635a os-registry.stups.zalan.do/acid/patroni:1.0 "/bin/bash /entrypoi 10 seconds ago Up 8 seconds 4001/tcp, 5432/tcp, 2380/tcp bravo_OR64g8bx + 67e611f2eca7 os-registry.stups.zalan.do/acid/patroni:1.0 "/bin/bash /entrypoi 11 seconds ago Up 10 seconds 2380/tcp, 4001/tcp, 5432/tcp bravo_si9no8iz + 6be871a11cb3 os-registry.stups.zalan.do/acid/patroni:1.0 "/bin/bash /entrypoi 12 seconds ago Up 10 seconds 4001/tcp, 5432/tcp, 2380/tcp bravo_etcd diff --git a/docker/dev_patroni_cluster.sh b/docker/dev_patroni_cluster.sh new file mode 100755 index 00000000..00a8264a --- /dev/null +++ b/docker/dev_patroni_cluster.sh @@ -0,0 +1,90 @@ +#!/bin/bash + +DOCKER_IMAGE="os-registry.stups.zalan.do/acid/patroni:1.0" +MEMBERS=3 + + +function usage() +{ + cat <<__EOF__ +Usage: $0 + +Options: + + --image IMAGE The Docker image to use for the cluster + --members INT The number of members for the cluster + --name NAME The name of the new cluster + +Examples: + + $0 --image ${DOCKER_IMAGE} + $0 + $0 --image ${DOCKER_IMAGE} --members=2 +__EOF__ +} + + +optspec=":-:" +while getopts "$optspec" optchar; do + case "${optchar}" in + -) + case "${OPTARG}" in + help) + usage + exit 0 + ;; + name) + PATRONI_SCOPE="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 )) + ;; + name=*) + PATRONI_SCOPE="${OPTARG#*=}" + ;; + image) + DOCKER_IMAGE="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 )) + ;; + image=*) + DOCKER_IMAGE="${OPTARG#*=}" + ;; + members) + MEMBERS="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 )) + ;; + members=*) + MEMBERS="${OPTARG#*=}" + ;; + *) + if [ "$OPTERR" = 1 ] && [ "${optspec:0:1}" != ":" ]; then + echo "Unknown option --${OPTARG}" >&2 + fi + ;; + esac;; + *) + if [ "$OPTERR" != 1 ] || [ "${optspec:0:1}" = ":" ]; then + echo "Non-option argument: '-${OPTARG}'" >&2 + usage + exit 1 + fi + ;; + esac +done + +function random_name() +{ + cat /dev/urandom | env LC_CTYPE=C tr -dc 'a-zA-Z0-9' | head -c 8 +} + +if [ -z ${PATRONI_SCOPE} ] +then + PATRONI_SCOPE=$(random_name) +fi + +etcd_container=$(docker run -d --name="${PATRONI_SCOPE}_etcd" "${DOCKER_IMAGE}" --etcd-only) +etcd_container_ip=$(docker inspect --format '{{ .NetworkSettings.IPAddress }}' ${etcd_container}) +echo "The etcd container is ${etcd_container}, ip=${etcd_container_ip}" + +for i in $(seq 1 "${MEMBERS}") +do + container_name=$(random_name) + patroni_container=$(docker run -d --name="${PATRONI_SCOPE}_${container_name}" "${DOCKER_IMAGE}" --etcd="${etcd_container_ip}:4001" --name="${PATRONI_SCOPE}") + patroni_container_ip=$(docker inspect --format '{{ .NetworkSettings.IPAddress }}' ${patroni_container}) + echo "Started Patroni container ${patroni_container}, ip=${patroni_container_ip}" +done diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 00000000..697bab66 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,122 @@ +#!/bin/bash + +function usage() +{ + cat <<__EOF__ +Usage: $0 + +Options: + + --etcd ETCD Provide an external etcd to connect to + --name NAME Give the cluster a specific name + --etcd-only Do not run Patroni, run a standalone etcd + +Examples: + + $0 --etcd=127.17.0.84:4001 + $0 --etcd-only + $0 + $0 --name=true_scotsman +__EOF__ +} + +DOCKER_IP=$(hostname --ip-address) +PATRONI_SCOPE=batman + +optspec=":vh-:" +while getopts "$optspec" optchar; do + case "${optchar}" in + -) + case "${OPTARG}" in + etcd-only) + exec etcd --data-dir /tmp/etcd.data \ + -advertise-client-urls=http://${DOCKER_IP}:4001 \ + -listen-client-urls=http://0.0.0.0:4001 \ + -listen-peer-urls=http://0.0.0.0:2380 + exit 0 + ;; + name) + PATRONI_SCOPE="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 )) + ;; + name=*) + PATRONI_SCOPE=${OPTARG#*=} + ;; + etcd) + ETCD_CLUSTER="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 )) + ;; + etcd=*) + ETCD_CLUSTER=${OPTARG#*=} + ;; + help) + usage + exit 0 + ;; + *) + if [ "$OPTERR" = 1 ] && [ "${optspec:0:1}" != ":" ]; then + echo "Unknown option --${OPTARG}" >&2 + fi + ;; + esac;; + *) + if [ "$OPTERR" != 1 ] || [ "${optspec:0:1}" = ":" ]; then + echo "Non-option argument: '-${OPTARG}'" >&2 + usage + exit 1 + fi + ;; + esac +done + +if [ -z ${ETCD_CLUSTER} ] +then + etcd --data-dir /tmp/etcd.data > /var/log/etcd.log 2> /var/log/etcd.err & + ETCD_CLUSTER="127.0.0.1:4001" +fi + +cat > /patroni/postgres.yml <<__EOF__ + +ttl: &ttl 30 +loop_wait: &loop_wait 10 +scope: &scope ${PATRONI_SCOPE} +restapi: + listen: 127.0.0.1:8008 + connect_address: 127.0.0.1:8008 +etcd: + scope: *scope + ttl: *ttl + host: ${ETCD_CLUSTER} +postgresql: + name: postgresql_${DOCKER_IP//./_} ## Replication slots do not allow dots in their name + scope: *scope + listen: 0.0.0.0:5432 + connect_address: ${DOCKER_IP}:5432 + data_dir: data/postgresql0 + maximum_lag_on_failover: 1048576 # 1 megabyte in bytes + pg_hba: + - host all all 0.0.0.0/0 md5 + - hostssl all all 0.0.0.0/0 md5 + - host replication replicator ${DOCKER_IP}/16 md5 + replication: + username: replicator + password: rep-pass + network: 127.0.0.1/32 + superuser: + password: zalando + admin: + username: admin + password: admin + parameters: + archive_mode: "on" + wal_level: hot_standby + archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f + max_wal_senders: 20 + listen_addresses: 0.0.0.0 + wal_keep_segments: 8 + archive_timeout: 1800s + max_replication_slots: 20 + hot_standby: "on" +__EOF__ + +cat /patroni/postgres.yml + +exec /patroni/patroni.py /patroni/postgres.yml diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100755 index ab76a0dd..00000000 --- a/entrypoint.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -etcd --data-dir /tmp/etcd.data > /var/log/etcd.log 2> /var/log/etcd.err & -exec /patroni/patroni.py /patroni/postgres0.yml "$@" From cdb0e43ed771fcf3fceffc587735aba99523e0a6 Mon Sep 17 00:00:00 2001 From: Feike Steenbergen Date: Wed, 5 Aug 2015 16:53:05 +0200 Subject: [PATCH 32/32] Dockerfile: Enable (undocumented) cheat mode to troubleshoot --- docker/entrypoint.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 697bab66..f46e7ea5 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -35,6 +35,9 @@ while getopts "$optspec" optchar; do -listen-peer-urls=http://0.0.0.0:2380 exit 0 ;; + cheat) + CHEAT=1 + ;; name) PATRONI_SCOPE="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 )) ;; @@ -69,7 +72,10 @@ done if [ -z ${ETCD_CLUSTER} ] then - etcd --data-dir /tmp/etcd.data > /var/log/etcd.log 2> /var/log/etcd.err & + etcd --data-dir /tmp/etcd.data \ + -advertise-client-urls=http://${DOCKER_IP}:4001 \ + -listen-client-urls=http://0.0.0.0:4001 \ + -listen-peer-urls=http://0.0.0.0:2380 > /var/log/etcd.log 2> /var/log/etcd.err & ETCD_CLUSTER="127.0.0.1:4001" fi @@ -119,4 +125,12 @@ __EOF__ cat /patroni/postgres.yml -exec /patroni/patroni.py /patroni/postgres.yml +if [ ! -z $CHEAT ] +then + while : + do + sleep 60 + done +else + exec /patroni/patroni.py /patroni/postgres.yml +fi