Repository: zalando/patroni

Issue:
Split-brain possible on simple configuration mistake
Currently Patroni will happily launch a 2nd primary if the "member" name is by accident a duplicate of the current leader.

Sure it's an operator error...but I think would be relatively easy to avoid it by first checking the current leader's (if exists) role directly over the Patroni API.

This situation won't happen on new setups as there's seems to be a check against that (although the message "released leader key voluntarily as data dir empty and currently leader" hints that the new duplicate node still grabbed the key), but rather with a "takeover" of some old Postgres clusters where the PGDATA exists.

**To Reproduce**

1. Start the 1st node as leader.
2. Start a 2nd node that will be bootstrapped as replica.
3. Change the replica config so that the member name (`name`) matches the leader's name.
4. Restart Patroni on 2nd node




Discussion:
I was able to reproduce this on the current version. I agree that it's operator error, but seems logical to check that a new node's name is unique when adding to a cluster. Am starting on a simple fix now.
So far, I've been working on a fix that involves a pre-check during the initialization of a new-node to make sure there aren't any existing nodes in the cluster with the same name. However, it struck me that the root cause of the issue seems to be that in the DCS, nodes are simply storing their name, which causes two nodes to have the view of being the leader. Another solution is to make it so that instead of writing name to the DCS, there's some way of assigning UUIDs to nodes automatically, so two nodes can have the same name and be fine.

Was curious what people thought was the better solution here. Personally, I'm inclined to stick with the simple pre-check when adding a node to an existing cluster. I think the UUID is a bit of an overkill, given that patroni _probably_ isn't used for more than a few tens of nodes, max. I'm also not sure if this is an operator error we want to correct for, vs just error out + notify that the configuration is incorrect. Also UUIDs in general seem like more headaches down the road.

_But_, I can see how a UUID approach might get more at the "core" of the issue. Open to input!

Resolving patch:
diff --git a/features/environment.py b/features/environment.py
--- a/features/environment.py
+++ b/features/environment.py
@@ -52,10 +52,9 @@ def start(self, max_wait_limit=5):
         self._log = open(os.path.join(self._output_dir, self._name + '.log'), 'a')
         self._handle = self._start()
 
-        assert self._has_started(), "Process {0} is not running after being started".format(self._name)
-
         max_wait_limit *= self._context.timeout_multiplier
         for _ in range(max_wait_limit):
+            assert self._has_started(), "Process {0} is not running after being started".format(self._name)
             if self._is_accessible():
                 break
             time.sleep(1)
@@ -344,6 +343,13 @@ def backup(self, dest=os.path.join('data', 'basebackup')):
                         '--datadir=' + os.path.join(self._work_directory, dest),
                         '--dbname=' + self.backup_source])
 
+    def read_patroni_log(self, level):
+        try:
+            with open(str(os.path.join(self._output_dir or '', self._name + ".log"))) as f:
+                return [line for line in f.readlines() if line[24:24 + len(level)] == level]
+        except IOError:
+            return []
+
 
 class ProcessHang(object):
 
@@ -827,7 +833,7 @@ def start(self, name, max_wait_limit=40, custom_config=None):
 
     def __getattr__(self, func):
         if func not in ['stop', 'query', 'write_label', 'read_label', 'check_role_has_changed_to',
-                        'add_tag_to_config', 'get_watchdog', 'patroni_hang', 'backup']:
+                        'add_tag_to_config', 'get_watchdog', 'patroni_hang', 'backup', 'read_patroni_log']:
             raise AttributeError("PatroniPoolController instance has no attribute '{0}'".format(func))
 
         def wrapper(name, *args, **kwargs):
diff --git a/features/steps/basic_replication.py b/features/steps/basic_replication.py
--- a/features/steps/basic_replication.py
+++ b/features/steps/basic_replication.py
@@ -9,6 +9,22 @@ def start_patroni(context, name):
     return context.pctl.start(name)
 
 
+@step('I start duplicate {name:w} on port {port:d}')
+def start_duplicate_patroni(context, name, port):
+    config = {
+        "name": name,
+        "restapi": {
+            "listen": "127.0.0.1:{0}".format(port)
+        }
+    }
+    try:
+        context.pctl.start('dup-' + name, custom_config=config)
+        assert False, "Process was expected to fail"
+    except AssertionError as e:
+        assert 'is not running after being started' in str(e),\
+            "No error was raised by duplicate start of {0} ".format(name)
+
+
 @step('I shut down {name:w}')
 def stop_patroni(context, name):
     return context.pctl.stop(name, timeout=60)
@@ -90,3 +106,10 @@ def replication_works(context, primary, 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()), primary, replica, time_limit))
+
+
+@then('there is a "{message}" {level:w} in the {node} patroni log')
+def check_patroni_log(context, message, level, node):
+    messsages_of_level = context.pctl.read_patroni_log(node, level)
+    assert any(message in line for line in messsages_of_level),\
+        "There was no {0} {1} in the {2} patroni log".format(message, level, node)
diff --git a/patroni/__main__.py b/patroni/__main__.py
--- a/patroni/__main__.py
+++ b/patroni/__main__.py
@@ -30,12 +30,15 @@ def __init__(self, config: 'Config') -> None:
 
         self.version = __version__
         self.dcs = get_dcs(self.config)
+        self.request = PatroniRequest(self.config, True)
+
+        self.ensure_unique_name()
+
         self.watchdog = Watchdog(self.config)
         self.load_dynamic_configuration()
 
         self.postgresql = Postgresql(self.config['postgresql'])
         self.api = RestApiServer(self, self.config['restapi'])
-        self.request = PatroniRequest(self.config, True)
         self.ha = Ha(self)
 
         self.tags = self.get_tags()
@@ -60,6 +63,23 @@ def load_dynamic_configuration(self) -> None:
                 logger.warning('Can not get cluster from dcs')
                 time.sleep(5)
 
+    def ensure_unique_name(self) -> None:
+        """A helper method to prevent splitbrain from operator naming error."""
+        from patroni.dcs import Member
+
+        cluster = self.dcs.get_cluster()
+        if not cluster:
+            return
+        member = cluster.get_member(self.config['name'], False)
+        if not isinstance(member, Member):
+            return
+        try:
+            _ = self.request(member, endpoint="/liveness")
+            logger.fatal("Can't start; there is already a node named '%s' running", self.config['name'])
+            sys.exit(1)
+        except Exception:
+            return
+
     def get_tags(self) -> Dict[str, Any]:
         return {tag: value for tag, value in self.config.get('tags', {}).items()
                 if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}


Test patch:
diff --git a/tests/test_patroni.py b/tests/test_patroni.py
--- a/tests/test_patroni.py
+++ b/tests/test_patroni.py
@@ -10,6 +10,7 @@
 from mock import Mock, PropertyMock, patch
 from patroni.api import RestApiServer
 from patroni.async_executor import AsyncExecutor
+from patroni.dcs import Cluster, Member
 from patroni.dcs.etcd import AbstractEtcdClientWithFailover
 from patroni.exceptions import DCSError
 from patroni.postgresql import Postgresql
@@ -202,3 +203,36 @@ def test_check_psycopg(self):
             self.assertRaises(SystemExit, check_psycopg)
         with patch('builtins.__import__', mock_import):
             self.assertRaises(SystemExit, check_psycopg)
+
+    def test_ensure_unique_name(self):
+        # None/empty cluster implies unique name
+        with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=None)):
+            self.assertIsNone(self.p.ensure_unique_name())
+        empty_cluster = Cluster.empty()
+        with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=empty_cluster)):
+            self.assertIsNone(self.p.ensure_unique_name())
+        without_members = empty_cluster._asdict()
+        del without_members['members']
+
+        # Cluster with members with different names implies unique name
+        okay_cluster = Cluster(
+            members=[Member(version=1, name="distinct", session=1, data={})],
+            **without_members
+        )
+        with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=okay_cluster)):
+            self.assertIsNone(self.p.ensure_unique_name())
+
+        # Cluster with a member with the same name that is running
+        bad_cluster = Cluster(
+            members=[Member(version=1, name="postgresql0", session=1, data={
+                "api_url": "https://127.0.0.1:8008",
+            })],
+            **without_members
+        )
+        with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=bad_cluster)):
+            # If the api of the running node cannot be reached, this implies unique name
+            with patch.object(self.p, 'request', Mock(side_effect=ConnectionError)):
+                self.assertIsNone(self.p.ensure_unique_name())
+            # Only if the api of the running node is reachable do we throw an error
+            with patch.object(self.p, 'request', Mock()):
+                self.assertRaises(SystemExit, self.p.ensure_unique_name)