Repository: zalando/patroni

Issue:
Callback for custom operations after initial bootstrap
Our usecase involves creating a lot of database clusters that need to have some initial database setup (some specific databases with extensions) created on the cluster (so, only once after the initial initdb bootstrapping) before becoming usable.

So we would like to have a way for Patroni to invoke the running of these tasks after the db is created but before the node is deemed ready. 

I was thinking on adding a new `on_bootstrap` hook that gets executed after the initial bootstrap currently done [here](https://github.com/zalando/patroni/blob/master/patroni/postgresql.py#L973) and blocks until completion. 

Would this make sense?, can you think of any other way to achieve this?



Discussion:
> I was thinking on adding a new on_bootstrap hook that gets executed after the initial bootstrap currently done here and blocks until completion. 

Since there is already `bootstrap` section in the configuration file, probably something like (see below) would be better than `on_bootstrap`

``` yaml
bootstrap:
  post_init: /your/script/or/binary
```

Patroni should run this executable and pass connection string as an argument. I think this step should be done right after starting up freshly initialized cluster: [patroni/postgresql.py#L975](https://github.com/zalando/patroni/blob/39d16fe2f95c87793a9227f6c32f2d0953a057b7/patroni/postgresql.py#L975), but before creation of all kind of additional users and roles.

Ok, we're going to work on adding it that way then.

Wouldn't it make sense to execute it _after_ the creation of roles? - so then we can pass the superuser info in the connection string.

> so then we can pass the superuser info in the connection string.

Superuser is created by initdb.

Please never pass password in the connection string. It has to be written into file with the permissions 0600 and filename should be exported as environment variable `PGPASSFILE`. This way any application using `libpq` will be able to "catch" it.
You can take [create_replica](https://github.com/zalando/patroni/blob/master/patroni/postgresql.py#L415) method as an example.


Resolving patch:
diff --git a/patroni/postgresql.py b/patroni/postgresql.py
--- a/patroni/postgresql.py
+++ b/patroni/postgresql.py
@@ -389,6 +389,30 @@ def _initialize(self, config):
             self.set_state('initdb failed')
         return ret
 
+    def run_bootstrap_post_init(self, config):
+        """
+        runs a script after initdb is called and waits until completion.
+        passed: cluster name, parameters
+        """
+        if 'post_init' in config:
+            cmd = config['post_init']
+            r = self._local_connect_kwargs
+            if 'user' in r:
+                connstring = 'postgres://{user}@{host}:{port}/{database}'.format(**r)
+            else:
+                connstring = 'postgres://{host}:{port}/{database}'.format(**r)
+
+            env = self.write_pgpass(r) if 'password' in r else None
+            try:
+                ret = subprocess.call(shlex.split(cmd) + [connstring], env=env)
+            except OSError:
+                logger.error('post_init script %s failed', cmd)
+                return False
+            if ret != 0:
+                logger.error('post_init script %s returned non-zero code %d', cmd, ret)
+                return False
+        return True
+
     def delete_trigger_file(self):
         if os.path.exists(self._trigger_file):
             os.unlink(self._trigger_file)
@@ -971,7 +995,7 @@ def clone(self, clone_member):
 
     def bootstrap(self, config):
         """ Initialize a new node from scratch and start it. """
-        if self._initialize(config) and self.start():
+        if self._initialize(config) and self.start() and self.run_bootstrap_post_init(config):
             for name, value in config['users'].items():
                 if name not in (self._superuser.get('username'), self._replication['username']):
                     self.create_or_update_role(name, value['password'], value.get('options', []))


Test patch:
diff --git a/tests/test_postgresql.py b/tests/test_postgresql.py
--- a/tests/test_postgresql.py
+++ b/tests/test_postgresql.py
@@ -395,15 +395,34 @@ def test_bootstrap(self):
         with patch('subprocess.call', Mock(return_value=1)):
             self.assertRaises(PostgresException, self.p.bootstrap, {})
 
+        with patch.object(Postgresql, 'run_bootstrap_post_init', Mock(return_value=False)):
+            self.assertRaises(PostgresException, self.p.bootstrap, {})
+
         self.p.bootstrap({'users': {'replicator': {'password': 'rep-pass', 'options': ['replication']}},
                           'pg_hba': ['host replication replicator 127.0.0.1/32 md5',
                                      'hostssl all all 0.0.0.0/0 md5',
-                                     'host all all 0.0.0.0/0 md5']})
+                                     'host all all 0.0.0.0/0 md5'],
+                          'post_init': '/bin/false'})
         with open(os.path.join(self.data_dir, 'pg_hba.conf')) as f:
             lines = f.readlines()
             assert 'host replication replicator 127.0.0.1/32 md5\n' in lines
             assert 'host all all 0.0.0.0/0 md5\n' in lines
 
+    def test_run_bootstrap_post_init(self):
+        with patch('subprocess.call', Mock(return_value=1)):
+            self.assertFalse(self.p.run_bootstrap_post_init({'post_init': '/bin/false'}))
+
+        with patch('subprocess.call', Mock(side_effect=OSError)):
+            self.assertFalse(self.p.run_bootstrap_post_init({'post_init': '/bin/false'}))
+
+        with patch('subprocess.call', Mock(return_value=0)) as mock_method:
+            self.assertTrue(self.p.run_bootstrap_post_init({'post_init': '/bin/false'}))
+
+        mock_method.assert_called()
+        args, kwargs = mock_method.call_args
+        assert 'PGPASSFILE' in kwargs['env'].keys()
+        self.assertEquals(args[0], ['/bin/false', 'postgres://test@localhost:5432/postgres'])
+
     @patch('patroni.postgresql.Postgresql.create_replica', Mock(return_value=0))
     def test_clone(self):
         self.p.clone(self.leader)