Repository: zalando/patroni

Issue:
Fails with error on start
Patroni terminates with the following error messages on startup:

 2018-06-05 21:41:41,692 INFO: Selected new etcd server http://192.168.221.45:2379
Traceback (most recent call last):
  File "/usr/local/bin/patroni", line 11, in <module>
    sys.exit(main())
  File "/usr/local/lib/python2.7/dist-packages/patroni/__init__.py", line 176, in main
    return patroni_main()
  File "/usr/local/lib/python2.7/dist-packages/patroni/__init__.py", line 143, in patroni_main
    patroni = Patroni()
  File "/usr/local/lib/python2.7/dist-packages/patroni/__init__.py", line 30, in __init__
    self.api = RestApiServer(self, self.config['restapi'])
  File "/usr/local/lib/python2.7/dist-packages/patroni/api.py", line 476, in __init__
    self.__initialize(config)
  File "/usr/local/lib/python2.7/dist-packages/patroni/api.py", line 520, in __initialize
    host, port = config['listen'].rsplit(':', 1)
ValueError: need more than 1 value to unpack

I have the following setup:

- Fresh Ubuntu 18.04 (running in LXD containers)
- stock postgreSQL
- followed the instructions on [linode.com:](https://www.linode.com/docs/databases/postgresql/create-a-highly-available-postgresql-cluster-using-patroni-and-haproxy/)

PS: I had a version conflict warning in this setup: 

/usr/local/lib/python2.7/dist-packages/requests/__init__.py:80: RequestsDependencyWarning: urllib3 (1.23) or chardet (3.0.4) doesn't match a supported version!

which I've found a work around for:

pip uninstall requests
pip install requests
pip uninstall docopt
pip install docopt

Source: [here](https://medium.com/@gloriapalmagonzalez/urllib3-1-22-or-chardet-2-2-1-doesnt-match-a-supported-version-requestsdependencywarning-97c36e0cb561)

No effect on the above error though


Discussion:
Please check your "listen" configuration option, it apparently needs host/IP + port separated by colon. I flag this as bug as `api.py` should handle this configuration error gracefully.

Resolving patch:
diff --git a/patroni/api.py b/patroni/api.py
--- a/patroni/api.py
+++ b/patroni/api.py
@@ -8,7 +8,8 @@
 import datetime
 
 from patroni.postgresql import PostgresConnectionException, PostgresException, Postgresql
-from patroni.utils import deep_compare, parse_bool, patch_config, Retry, RetryFailedError, parse_int, tzutc
+from patroni.utils import deep_compare, parse_bool, patch_config, Retry, \
+    RetryFailedError, parse_int, split_host_port, tzutc
 from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
 from six.moves.socketserver import ThreadingMixIn
 from threading import Thread
@@ -473,6 +474,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
 
     def __init__(self, patroni, config):
         self.patroni = patroni
+        self.__listen = None
         self.__initialize(config)
         self.__set_config_parameters(config)
         self.daemon = True
@@ -507,18 +509,25 @@ def check_auth_header(self, auth_header):
     def __get_ssl_options(config):
         return {option: config[option] for option in ['certfile', 'keyfile'] if option in config}
 
-    def __set_connection_string(self, connect_address):
-        self.connection_string = '{0}://{1}/patroni'.format(self.__protocol, connect_address or self.__listen)
-
     def __set_config_parameters(self, config):
         self.__auth_key = base64.b64encode(config['auth'].encode('utf-8')).decode('utf-8') if 'auth' in config else None
-        self.__set_connection_string(config.get('connect_address'))
+        self.connection_string = '{0}://{1}/patroni'.format(self.__protocol,
+                                                            config.get('connect_address') or self.__listen)
 
     def __initialize(self, config):
-        self.__ssl_options = self.__get_ssl_options(config)
+        try:
+            host, port = split_host_port(config['listen'], None)
+        except Exception:
+            raise ValueError('Invalid "restapi" config: expected <HOST>:<PORT> for "listen", but got "{0}"'
+                             .format(config['listen']))
+
+        if self.__listen is not None:  # changing config in runtime
+            self.shutdown()
+
         self.__listen = config['listen']
-        host, port = config['listen'].rsplit(':', 1)
-        HTTPServer.__init__(self, (host, int(port)), RestApiHandler)
+        self.__ssl_options = self.__get_ssl_options(config)
+
+        HTTPServer.__init__(self, (host, port), RestApiHandler)
         Thread.__init__(self, target=self.serve_forever)
         self._set_fd_cloexec(self.socket)
 
@@ -530,11 +539,13 @@ def __initialize(self, config):
             import ssl
             self.socket = ssl.wrap_socket(self.socket, server_side=True, **self.__ssl_options)
             self.__protocol = 'https'
-        self.__set_connection_string(config.get('connect_address'))
+        return True
 
     def reload_config(self, config):
-        self.__set_config_parameters(config)
-        if self.__listen != config['listen'] or self.__ssl_options != self.__get_ssl_options(config):
-            self.shutdown()
-            self.__initialize(config)
+        if 'listen' not in config:  # changing config in runtime
+            raise ValueError('Can not find "restapi.listen" config')
+
+        elif (self.__listen != config['listen'] or self.__ssl_options != self.__get_ssl_options(config)) \
+                and self.__initialize(config):
             self.start()
+        self.__set_config_parameters(config)


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -123,16 +123,14 @@ def sendall(self, *args, **kwargs):
 
 class MockRestApiServer(RestApiServer):
 
-    def __init__(self, Handler, request):
+    def __init__(self, Handler, request, config=None):
         self.socket = 0
         self.serve_forever = Mock()
         BaseHTTPServer.HTTPServer.__init__ = Mock()
         MockRestApiServer._BaseServer__is_shut_down = Mock()
         MockRestApiServer._BaseServer__shutdown_request = True
-        config = {'listen': '127.0.0.1:8008', 'auth': 'test:test'}
+        config = config or {'listen': '127.0.0.1:8008', 'auth': 'test:test', 'certfile': 'dumb'}
         super(MockRestApiServer, self).__init__(MockPatroni(), config)
-        config['certfile'] = 'dumb'
-        self.reload_config(config)
         Handler(MockRequest(request), ('0.0.0.0', 8080), self)
 
 
@@ -380,3 +378,15 @@ def test_do_POST_failover(self):
         post = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: '
         MockRestApiServer(RestApiHandler, post + '14\n\n{"leader":"1"}')
         MockRestApiServer(RestApiHandler, post + '37\n\n{"candidate":"2","scheduled_at": "1"}')
+
+
+@patch('ssl.wrap_socket', Mock(return_value=0))
+class TestRestApiServer(unittest.TestCase):
+
+    def test_reload_config(self):
+        bad_config = {'listen': 'foo'}
+        self.assertRaises(ValueError, MockRestApiServer, None, '', bad_config)
+        srv = MockRestApiServer(lambda a1, a2, a3: None, '')
+        self.assertRaises(ValueError, srv.reload_config, bad_config)
+        self.assertRaises(ValueError, srv.reload_config, {})
+        srv.reload_config({'listen': '127.0.0.2:8008'})