From 1c30948ef9cb126b27c166c4309ef20fc9206982 Mon Sep 17 00:00:00 2001 From: Alexander Kukushkin Date: Wed, 1 Jun 2016 17:06:31 +0200 Subject: [PATCH] Implement PUT /config and enhance some checks --- features/patroni_api.feature | 2 ++ patroni/api.py | 61 ++++++++++++++++++++++++++---------- tests/test_api.py | 54 ++++++++++++++++++++----------- 3 files changed, 83 insertions(+), 34 deletions(-) diff --git a/features/patroni_api.feature b/features/patroni_api.feature index 0702f308..04d31765 100644 --- a/features/patroni_api.feature +++ b/features/patroni_api.feature @@ -18,6 +18,8 @@ Scenario: check API requests on a stand-alone server 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 + When I issue a POST request to http://127.0.0.1:8008/failover with {"foo": "bar"} + Then I receive a response code 400 And I receive a response text "No values given for required parameters leader and candidate" Scenario: check local configuration reload diff --git a/patroni/api.py b/patroni/api.py index 982a3569..73a74d1c 100644 --- a/patroni/api.py +++ b/patroni/api.py @@ -9,7 +9,7 @@ import datetime import pytz from patroni.exceptions import PostgresConnectionException -from patroni.utils import Retry, RetryFailedError +from patroni.utils import deep_compare, Retry, RetryFailedError from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer from six.moves.socketserver import ThreadingMixIn from threading import Thread @@ -127,17 +127,46 @@ class RestApiHandler(BaseHTTPRequestHandler): is_changed = True return is_changed + def _read_json_content(self): + if 'content-length' not in self.headers: + return self.send_error(411) + try: + content_length = int(self.headers.get('content-length')) + request = json.loads(self.rfile.read(content_length).decode('utf-8')) + if isinstance(request, dict) and request: + return request + except Exception: + logger.exception('Bad request') + self.send_error(400) + @check_auth def do_PATCH_config(self): - content_length = int(self.headers.get('content-length', 0)) - request = json.loads(self.rfile.read(content_length).decode('utf-8')) - cluster = self.server.patroni.ha.dcs.get_cluster() - data = cluster.config.data.copy() - if RestApiHandler._patch_config(data, request): - self.server.patroni.ha.dcs.set_config_value(json.dumps(data, separators=(',', ':')), cluster.config.index) - self._write_json_response(200, data) - else: - self._write_response(304, '', '') + request = self._read_json_content() + if request: + cluster = self.server.patroni.ha.dcs.get_cluster() + data = cluster.config.data.copy() + if RestApiHandler._patch_config(data, request): + value = json.dumps(data, separators=(',', ':')) + if self.server.patroni.ha.dcs.set_config_value(value, cluster.config.index): + self._write_json_response(200, data) + else: + self.send_error(409) + else: + self.send_error(304) + + @check_auth + def do_PUT_config(self): + request = self._read_json_content() + if request: + cluster = self.server.patroni.ha.dcs.get_cluster() + if deep_compare(request, cluster.config.data): + self.send_error(304) + else: + value = json.dumps(request, separators=(',', ':')) + if self.server.patroni.ha.dcs.set_config_value(value): + self._write_json_response(200, request) + else: + self.send_error(502) @check_auth def do_POST_reload(self): @@ -184,7 +213,8 @@ class RestApiHandler(BaseHTTPRequestHandler): self._write_response(status_code, data) def poll_failover_result(self, leader, candidate): - for _ in range(0, 15): + timeout = 10 if self.server.patroni.nap_time < 10 else self.server.patroni.nap_time + for _ in range(0, timeout*2): time.sleep(1) try: cluster = self.server.patroni.dcs.get_cluster() @@ -217,11 +247,10 @@ class RestApiHandler(BaseHTTPRequestHandler): @check_auth def do_POST_failover(self): - content_length = int(self.headers.get('content-length', 0)) - try: - request = json.loads(self.rfile.read(content_length).decode('utf-8')) - except ValueError: - request = {} + request = self._read_json_content() + if not request: + return + leader = request.get('leader') candidate = request.get('candidate') or request.get('member') scheduled_at = request.get('scheduled_at') diff --git a/tests/test_api.py b/tests/test_api.py index fe569836..befa120e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -49,6 +49,7 @@ class MockHa(object): class MockPatroni(object): + nap_time = 10 config = Mock() postgresql = MockPostgresql() ha = MockHa() @@ -126,12 +127,30 @@ class TestRestApiHandler(unittest.TestCase): def test_do_PATCH_config(self, mock_dcs): config = {'postgresql': {'use_slots': False, 'use_pg_rewind': True, 'parameters': {'wal_level': 'logical'}}} mock_dcs.get_cluster.return_value.config = ClusterConfig.from_node(1, json.dumps(config)) - request = 'PATCH /config HTTP/1.0' + self._authorization + '\nContent-Length: ' - self.assertIsNotNone(MockRestApiServer(RestApiHandler, request + '2\n\n{}')) + request = 'PATCH /config HTTP/1.0' + self._authorization + self.assertIsNotNone(MockRestApiServer(RestApiHandler, request)) + request += '\nContent-Length: ' + self.assertIsNotNone(MockRestApiServer(RestApiHandler, request + '34\n\n{"postgresql":{"use_slots":false}}')) config['ttl'] = 5 config['postgresql'].update({'use_slots': True, "parameters": None}) config = json.dumps(config) - MockRestApiServer(RestApiHandler, request + str(len(config)) + '\n\n' + config) + request += str(len(config)) + '\n\n' + config + MockRestApiServer(RestApiHandler, request) + mock_dcs.set_config_value.return_value = False + MockRestApiServer(RestApiHandler, request) + + @patch.object(MockHa, 'dcs') + def test_do_PUT_config(self, mock_dcs): + mock_dcs.get_cluster.return_value.config = ClusterConfig.from_node(1, '{}') + request = 'PUT /config HTTP/1.0' + self._authorization + '\nContent-Length: ' + self.assertIsNotNone(MockRestApiServer(RestApiHandler, request + '2\n\n{}')) + config = '{"foo": "bar"}' + request += str(len(config)) + '\n\n' + config + MockRestApiServer(RestApiHandler, request) + mock_dcs.set_config_value.return_value = False + MockRestApiServer(RestApiHandler, request) + mock_dcs.get_cluster.return_value.config = ClusterConfig.from_node(1, config) + MockRestApiServer(RestApiHandler, request) @patch.object(MockPatroni, 'sighup_handler', Mock(side_effect=Exception)) def test_do_POST_reload(self): @@ -170,18 +189,20 @@ class TestRestApiHandler(unittest.TestCase): def test_do_POST_failover(self, dcs): cluster = dcs.get_cluster.return_value - request = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: 0\n\n' + post = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: ' + + MockRestApiServer(RestApiHandler, post + '7\n\n{"1":2}') + + request = post + '0\n\n' MockRestApiServer(RestApiHandler, request) cluster.leader.name = 'postgresql1' MockRestApiServer(RestApiHandler, request) - request = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: 25\n\n{"leader": "postgresql1"}' - MockRestApiServer(RestApiHandler, request) + MockRestApiServer(RestApiHandler, post + '25\n\n{"leader": "postgresql1"}') cluster.leader.name = 'postgresql2' - request = 'POST /failover HTTP/1.0' + self._authorization +\ - '\nContent-Length: 53\n\n{"leader": "postgresql1", "candidate": "postgresql2"}' + request = post + '53\n\n{"leader": "postgresql1", "candidate": "postgresql2"}' MockRestApiServer(RestApiHandler, request) cluster.leader.name = 'postgresql1' @@ -207,24 +228,21 @@ class TestRestApiHandler(unittest.TestCase): MockRestApiServer(RestApiHandler, request) # Valid future date - request = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: 103\n\n{"leader": ' +\ - '"postgresql1", "member": "postgresql2", "scheduled_at": "6016-02-15T18:13:30.568224+01:00"}' + request = post + '103\n\n{"leader": "postgresql1", "member": "postgresql2",' +\ + ' "scheduled_at": "6016-02-15T18:13:30.568224+01:00"}' MockRestApiServer(RestApiHandler, request) with patch.object(MockPatroni, 'dcs') as d: d.manual_failover.return_value = False MockRestApiServer(RestApiHandler, request) # Exception: No timezone specified - request = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: 97\n\n{"leader": ' +\ - '"postgresql1", "member": "postgresql2", "scheduled_at": "6016-02-15T18:13:30.568224"}' + request = post + '97\n\n{"leader": "postgresql1", "member": "postgresql2",' +\ + ' "scheduled_at": "6016-02-15T18:13:30.568224"}' MockRestApiServer(RestApiHandler, request) # Exception: Scheduled in the past - request = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: 103\n\n{"leader": ' +\ - '"postgresql1", "member": "postgresql2", "scheduled_at": "1016-02-15T18:13:30.568224+01:00"}' - MockRestApiServer(RestApiHandler, request) + request = post + '103\n\n{"leader": "postgresql1", "member": "postgresql2", "scheduled_at": "' + MockRestApiServer(RestApiHandler, request + '1016-02-15T18:13:30.568224+01:00"}') # Invalid date - request = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: 103\n\n{"leader": ' +\ - '"postgresql1", "member": "postgresql2", "scheduled_at": "2010-02-29T18:13:30.568224+01:00"}' - self.assertIsNotNone(MockRestApiServer(RestApiHandler, request)) + self.assertIsNotNone(MockRestApiServer(RestApiHandler, request + '2010-02-29T18:13:30.568224+01:00"}'))