diff --git a/README.rst b/README.rst index d353881e..80861070 100644 --- a/README.rst +++ b/README.rst @@ -3,7 +3,7 @@ Patroni: A Template for PostgreSQL HA with ZooKeeper, etcd or Consul ------------------------------------------------------------ -Patroni was previously known as Governor. +Patroni originates from Compose Governor and includes plenty of new features. *There are many ways to run high availability with PostgreSQL. Here, we present a template for you to create your own customized, high-availability diff --git a/features/basic_replication.feature b/features/basic_replication.feature index ea5176be..0f5799e0 100644 --- a/features/basic_replication.feature +++ b/features/basic_replication.feature @@ -8,22 +8,9 @@ Feature: basic replication When I add the table foo to postgres0 Then table foo is present on postgres1 after 20 seconds - Scenario: check local configuration reload - Given I issue an empty POST request to http://127.0.0.1:8008/reload - Then I receive a response code 304 - When I add tag new_tag new_value to postgres0 config - And I issue an empty POST request to http://127.0.0.1:8008/reload - Then I receive a response code 202 - - Scenario: check dynamic configuration change via DCS - Given I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "loop_wait": 5, "postgresql": {"parameters": {"max_connections": 101}}} - Then Response on GET http://127.0.0.1:8008/patroni contains restart_pending after 11 seconds - And Response on GET http://127.0.0.1:8009/patroni contains restart_pending after 11 seconds - And Response on GET http://127.0.0.1:8008/patroni contains new_value after 1 seconds - Scenario: check the basic failover And I kill postgres0 - Then postgres1 role is the primary after 22 seconds + Then postgres1 role is the primary after 32 seconds When I start postgres0 Then postgres0 role is the secondary after 20 seconds When I add the table bar to postgres1 diff --git a/features/patroni_api.feature b/features/patroni_api.feature index 22dce74f..a94ad8bc 100644 --- a/features/patroni_api.feature +++ b/features/patroni_api.feature @@ -15,14 +15,31 @@ Scenario: check API requests on a stand-alone server And I receive a response text "I am the leader, can not reinitialize" When I issue a POST request to http://127.0.0.1:8008/failover with {"leader": "postgres0"} Then I receive a response code 500 - And I receive a response text "failover is not possible: cluster does not have members except leader" + And I receive a response text failover is not possible: cluster does not have members except leader When I issue an empty POST request to http://127.0.0.1:8008/failover Then I receive a response code 400 And I receive a response text "No values given for required parameters leader and candidate" -Scenario: check API requests for the primary-replica pair - Given I start postgres1 +Scenario: check local configuration reload + Given I issue an empty POST request to http://127.0.0.1:8008/reload + Then I receive a response code 304 + When I add tag new_tag new_value to postgres0 config + And I issue an empty POST request to http://127.0.0.1:8008/reload + Then I receive a response code 202 + +Scenario: check dynamic configuration change via DCS + Given I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "loop_wait": 1, "postgresql": {"parameters": {"max_connections": 101}}} + And I start postgres1 And replication works from postgres0 to postgres1 after 20 seconds + When I issue a GET request to http://127.0.0.1:8008/config + Then I receive a response code 200 + And I receive a response loop_wait 1 + When I issue a GET request to http://127.0.0.1:8008/patroni + Then I receive a response code 200 + And I receive a response restart_pending True + And I receive a response tags {'tag': 'new_value'} + +Scenario: check API requests for the primary-replica pair When I issue a GET request to http://127.0.0.1:8009/replica Then I receive a response code 200 And I receive a response state running @@ -50,4 +67,3 @@ Scenario: check the scheduled failover And postgres0 role is the primary after 5 seconds And postgres1 role is the secondary after 10 seconds And replication works from postgres0 to postgres1 after 25 seconds - diff --git a/features/steps/basic_replication.py b/features/steps/basic_replication.py index 05acf288..b59a7639 100644 --- a/features/steps/basic_replication.py +++ b/features/steps/basic_replication.py @@ -1,5 +1,4 @@ import psycopg2 as pg -import requests from behave import step, then from time import sleep, time @@ -53,20 +52,3 @@ def replication_works(context, master, replica, time_limit): When I add the table test_{0} to {1} Then table test_{0} is present on {2} after {3} seconds """.format(int(time()), master, replica, time_limit)) - - -@then('Response on GET {url} contains {value} after {timeout:d} seconds') -def check_http_response(context, url, value, timeout): - for _ in range(int(timeout)): - r = requests.get(url) - if value in r.content.decode('utf-8'): - break - sleep(1) - else: - assert False,\ - "Value {0} is not present in response after {1} seconds".format(value, timeout) - - -@step('I add tag {tag:w} {value:w} to {pg_name:w} config') -def add_tag_to_config(context, tag, value, pg_name): - context.pctl.add_tag_to_config(pg_name, tag, value) diff --git a/features/steps/patroni_api.py b/features/steps/patroni_api.py index d41eb373..1ecd1af7 100644 --- a/features/steps/patroni_api.py +++ b/features/steps/patroni_api.py @@ -3,6 +3,7 @@ import parse import pytz import requests import time +import yaml from behave import register_type, step, then from datetime import datetime, timedelta @@ -35,6 +36,23 @@ def sleep_for_n_seconds(context, value): time.sleep(int(value)) +def _set_response(context, response): + context.status_code = response.status_code + data = response.content.decode('utf-8') + ct = response.headers.get('content-type', '') + if ct.startswith('application/json') or\ + ct.startswith('text/yaml') or\ + ct.startswith('text/x-yaml') or\ + ct.startswith('application/yaml') or\ + ct.startswith('application/x-yaml'): + try: + context.response = yaml.safe_load(data) + except ValueError: + context.response = data + else: + context.response = data + + @step('I issue a GET request to {url:url}') def do_get(context, url): try: @@ -43,11 +61,7 @@ def do_get(context, url): context.status_code = None context.response = None else: - context.status_code = r.status_code - try: - context.response = r.json() - except ValueError: - context.response = r.content.decode('utf-8') + _set_response(context, r) @step('I issue an empty POST request to {url:url}') @@ -67,11 +81,7 @@ def do_request(context, request_method, url, data): context.status_code = None context.response = None else: - context.status_code = r.status_code - try: - context.response = r.json() - except ValueError: - context.response = r.content.decode('utf-8') + _set_response(context, r) @then('I receive a response {component:w} {data}') @@ -83,7 +93,7 @@ def check_response(context, component, data): assert context.response == data.strip('"'), "response {0} does not contain {1}".format(context.response, data) else: assert component in context.response, "{0} is not part of the response".format(component) - assert context.response[component] == data, "{0} does not contain {1}".format(component, data) + assert str(context.response[component]) == str(data), "{0} does not contain {1}".format(component, data) @step('I issue a scheduled failover at {at_url:url} from {from_host:w} to {to_host:w} in {in_seconds:d} seconds') @@ -91,3 +101,8 @@ def scheduled_failover(context, at_url, from_host, to_host, in_seconds): context.execute_steps(u""" Given I issue a POST request to {0}/failover with {{"leader": "{1}", "candidate": "{2}", "scheduled_at": "{3}"}} """.format(at_url, from_host, to_host, datetime.now(pytz.utc) + timedelta(seconds=int(in_seconds)))) + + +@step('I add tag {tag:w} {value:w} to {pg_name:w} config') +def add_tag_to_config(context, tag, value, pg_name): + context.pctl.add_tag_to_config(pg_name, tag, value) diff --git a/patroni/ha.py b/patroni/ha.py index ad791be5..503d09df 100644 --- a/patroni/ha.py +++ b/patroni/ha.py @@ -73,7 +73,7 @@ class Ha(object): logger.info('bootstrapped %s', msg) cluster = self.dcs.get_cluster() node_to_follow = self._get_node_to_follow(cluster) - self.state_handler.follow(node_to_follow, True) + self.state_handler.follow(node_to_follow, cluster.leader, True) else: logger.error('failed to bootstrap %s', msg) self.state_handler.remove_data_directory() @@ -138,7 +138,7 @@ class Ha(object): if not self.state_handler.check_recovery_conf(node_to_follow) or recovery: self._async_executor.schedule('changing primary_conninfo and restarting') - self._async_executor.run_async(self.state_handler.follow, (node_to_follow, recovery)) + self._async_executor.run_async(self.state_handler.follow, (node_to_follow, self.cluster.leader, recovery)) return ret def enforce_master_role(self, message, promote_message): @@ -280,9 +280,11 @@ class Ha(object): self.touch_member() self.dcs.reset_cluster() sleep(2) # Give a time to somebody to promote - self.recover() + cluster = self.dcs.get_cluster() + node_to_follow = self._get_node_to_follow(cluster) + self.state_handler.follow(node_to_follow, True) else: - self.state_handler.follow(None) + self.state_handler.follow(None, None) def process_manual_failover_from_leader(self): failover = self.cluster.failover diff --git a/patroni/postgresql.py b/patroni/postgresql.py index aeb229a1..fd8a3692 100644 --- a/patroni/postgresql.py +++ b/patroni/postgresql.py @@ -651,8 +651,8 @@ class Postgresql(object): except OSError: logger.exception("Unable to list %s", status_dir) - def follow(self, leader, recovery=False): - if self.check_recovery_conf(leader) and not recovery: + def follow(self, member, leader, recovery=False): + if self.check_recovery_conf(member) and not recovery: return True change_role = self.role == 'master' need_rewind = change_role and self.can_rewind @@ -677,14 +677,14 @@ class Postgresql(object): opts.update({'archive_mode': 'on', 'archive_command': 'false'}) self.single_user_mode(options=opts) if self.rewind(leader): - self.write_recovery_conf(leader) + self.write_recovery_conf(member) ret = self.start() else: logger.error("unable to rewind the former master") self.remove_data_directory() ret = True else: # do not rewind until the leader becomes available - self.write_recovery_conf(leader) + self.write_recovery_conf(member) ret = self.restart() if change_role and ret: self.call_nowait(ACTION_ON_ROLE_CHANGE) diff --git a/tests/test_postgresql.py b/tests/test_postgresql.py index 84f33e2e..38d89ed1 100644 --- a/tests/test_postgresql.py +++ b/tests/test_postgresql.py @@ -234,22 +234,22 @@ class TestPostgresql(unittest.TestCase): @patch('patroni.postgresql.Postgresql.write_pgpass', MagicMock(return_value=dict())) @patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string)) def test_follow(self, mock_pg_rewind): - self.p.follow(None) - self.p.follow(self.leader) - self.p.follow(Leader(-1, 28, self.other)) + self.p.follow(None, None) + self.p.follow(self.leader, self.leader) + self.p.follow(Leader(-1, 28, self.other), self.leader) self.p.rewind = mock_pg_rewind - self.p.follow(self.leader) + self.p.follow(self.leader, self.leader) with mock.patch('os.path.islink', MagicMock(return_value=True)): with mock.patch('patroni.postgresql.Postgresql.can_rewind', new_callable=PropertyMock(return_value=True)): with mock.patch('os.unlink', MagicMock(return_value=True)): - self.p.follow(self.leader, recovery=True) + self.p.follow(self.leader, self.leader, recovery=True) with mock.patch('patroni.postgresql.Postgresql.can_rewind', new_callable=PropertyMock(return_value=True)): self.p.rewind.return_value = True - self.p.follow(self.leader, recovery=True) + self.p.follow(self.leader, self.leader, recovery=True) self.p.rewind.return_value = False - self.p.follow(self.leader, recovery=True) + self.p.follow(self.leader, self.leader, recovery=True) with mock.patch('patroni.postgresql.Postgresql.check_recovery_conf', MagicMock(return_value=True)): - self.assertTrue(self.p.follow(None)) + self.assertTrue(self.p.follow(None, None)) @patch('subprocess.check_output', Mock(return_value=0, side_effect=pg_controldata_string)) def test_can_rewind(self): @@ -425,7 +425,7 @@ class TestPostgresql(unittest.TestCase): def test_cleanup_archive_status(self, mock_file, mock_link, mock_remove, mock_unlink): ap = os.path.join(self.data_dir, 'pg_xlog', 'archive_status/') self.p.cleanup_archive_status() - mock_remove.assert_has_calls([mock.call(ap+'a'), mock.call(ap+'b'), mock.call(ap+'c')]) + mock_remove.assert_has_calls([mock.call(ap + 'a'), mock.call(ap + 'b'), mock.call(ap + 'c')]) mock_unlink.assert_not_called() mock_remove.reset_mock() @@ -433,7 +433,7 @@ class TestPostgresql(unittest.TestCase): mock_file.return_value = False mock_link.return_value = True self.p.cleanup_archive_status() - mock_unlink.assert_has_calls([mock.call(ap+'a'), mock.call(ap+'b'), mock.call(ap+'c')]) + mock_unlink.assert_has_calls([mock.call(ap + 'a'), mock.call(ap + 'b'), mock.call(ap + 'c')]) mock_remove.assert_not_called() mock_unlink.reset_mock()