diff --git a/Dockerfile b/Dockerfile index 84b9cdf6..362c9bf4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ RUN pip install python-etcd psycopg2 ENV PATH /usr/lib/postgresql/${PGVERSION}/bin:$PATH ADD patroni.py /patroni.py +ADD patronictl.py /patronictl.py ADD patroni/ /patroni ENV ETCDVERSION 2.0.13 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 9edb2120..b549cc46 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -79,7 +79,12 @@ then ETCD_CLUSTER="127.0.0.1:4001" fi -cat > /patroni/postgres.yml <<__EOF__ +mkdir -p ~postgres/.config/patroni +cat > ~postgres/.config/patroni/patronictl.yaml <<__EOF__ +{dcs_api: 'etcd://${ETCD_CLUSTER}', namespace: /service/} +__EOF__ + +cat > /patroni/postgres.yaml <<__EOF__ ttl: &ttl 30 loop_wait: &loop_wait 10 @@ -126,7 +131,7 @@ postgresql: hot_standby: "on" __EOF__ -cat /patroni/postgres.yml +cat /patroni/postgres.yaml if [ ! -z $CHEAT ] then @@ -135,5 +140,5 @@ then sleep 60 done else - exec python /patroni.py /patroni/postgres.yml + exec python /patroni.py /patroni/postgres.yaml fi diff --git a/patroni/ctl.py b/patroni/ctl.py index 3d457c9f..635b0a89 100644 --- a/patroni/ctl.py +++ b/patroni/ctl.py @@ -168,7 +168,9 @@ def watching(w, watch, max_count=None, clear=True): yield 0 -def build_connect_parameters(conn_url, connect_parameters={}): +def build_connect_parameters(conn_url, connect_parameters=None): + if connect_parameters is None: + connect_parameters = {} params = connect_parameters.copy() parsed = parseurl(conn_url) params['host'] = parsed['host'] @@ -200,7 +202,9 @@ def get_any_member(cluster, role='master', member=None): return None -def get_cursor(cluster, role='master', member=None, connect_parameters={}): +def get_cursor(cluster, role='master', member=None, connect_parameters=None): + if connect_parameters is None: + connect_parameters = {} member = get_any_member(cluster=cluster, role=role, member=member) if member is None: return None @@ -252,6 +256,8 @@ def dsn(cluster_name, config_file, dcs, role, member): @option_format @click.option('--format', help='Output format (pretty, json)', default='tsv') @click.option('--file', '-f', help='Execute the SQL commands from this file', type=click.File('rb')) +@click.option('--password', help='force password prompt', is_flag=True) +@click.option('-U', '--username', help='database user name', type=str) @option_dcs @option_watch @option_watchrefresh @@ -260,6 +266,7 @@ def dsn(cluster_name, config_file, dcs, role, member): @click.option('--member', '-m', help='Query a specific member', type=str) @click.option('--delimiter', help='The column delimiter', default='\t') @click.option('--command', '-c', help='The SQL commands to execute') +@click.option('-d', '--dbname', help='database name to connect to', type=str) def query( cluster_name, config_file, @@ -271,6 +278,9 @@ def query( delimiter, command, file, + password, + username, + dbname, format='tsv', ): if role is not None and member is not None: @@ -281,6 +291,17 @@ def query( if file is not None and command is not None: raise PatroniCtlException('--file and --command are mutually exclusive options') + if file is None and command is None: + raise PatroniCtlException('You need to specify either --command or --file') + + connect_parameters = dict() + if username: + connect_parameters['user'] = username + if password: + connect_parameters['password'] = click.prompt('Password', hide_input=True, type=str) + if dbname: + connect_parameters['database'] = dbname + if file is not None: command = file.read() @@ -289,17 +310,20 @@ def query( cursor = None for _ in watching(w, watch, clear=False): - output, cursor = query_member(cluster=cluster, cursor=cursor, member=member, role=role, command=command) + output, cursor = query_member(cluster=cluster, cursor=cursor, member=member, role=role, command=command, + connect_parameters=connect_parameters) print_output(None, output, format=format, delimiter=delimiter) if cursor is None: cluster = dcs.get_cluster() -def query_member(cluster, cursor, member, role, command): +def query_member(cluster, cursor, member, role, command, connect_parameters=None): + if connect_parameters is None: + connect_parameters = {} try: if cursor is None: - cursor = get_cursor(cluster, role=role, member=member) + cursor = get_cursor(cluster, role=role, member=member, connect_parameters=connect_parameters) if cursor is None: if role is None: diff --git a/patroni/exceptions.py b/patroni/exceptions.py index 43f54e7f..d07e6426 100644 --- a/patroni/exceptions.py +++ b/patroni/exceptions.py @@ -1,3 +1,6 @@ +from click import ClickException + + class PatroniException(Exception): """Parent class for all kind of exceptions related to selected distributed configuration store""" @@ -13,7 +16,7 @@ class PatroniException(Exception): return repr(self.value) -class PatroniCtlException(Exception): +class PatroniCtlException(ClickException): pass diff --git a/tests/test_ctl.py b/tests/test_ctl.py index 093ead32..81f825aa 100644 --- a/tests/test_ctl.py +++ b/tests/test_ctl.py @@ -103,35 +103,35 @@ y''') result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader other N''') - assert 'Aborting failover' in str(result.exception) + assert 'Aborting failover' in str(result.output) result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader leader y''') - assert 'target and source are the same' in str(result.exception) + assert 'target and source are the same' in str(result.output) result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader Reality y''') - assert 'Reality does not exist' in str(result.exception) + assert 'Reality does not exist' in str(result.output) result = runner.invoke(ctl, ['failover', 'dummy', '--force']) assert 'Failing over to new leader' in result.output result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='dummy') - assert 'is not the leader of cluster' in str(result.exception) + assert 'is not the leader of cluster' in str(result.output) with patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_only_leader())): result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader other y''') - assert 'No candidates found to failover to' in str(result.exception) + assert 'No candidates found to failover to' in str(result.output) with patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_without_leader())): result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader other y''') - assert 'This cluster has no master' in str(result.exception) + assert 'This cluster has no master' in str(result.output) with patch('patroni.ctl.post_patroni', Mock(side_effect=Exception())): result = runner.invoke(ctl, ['failover', 'dummy', '--dcs', '8.8.8.8'], input='''leader @@ -150,13 +150,13 @@ y''') # with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader())): # result = runner.invoke(ctl, ['failover', 'alpha', '--dcs', '8.8.8.8'], input='nonsense') -# assert 'is not the leader of cluster' in str(result.exception) +# assert 'is not the leader of cluster' in str(result.output) # result = runner.invoke(ctl, ['failover', 'alpha', '--dcs', '8.8.8.8', '--master', 'nonsense']) - # assert 'is not the leader of cluster' in str(result.exception) + # assert 'is not the leader of cluster' in str(result.output) # result = runner.invoke(ctl, ['failover', 'alpha', '--dcs', '8.8.8.8'], input='leader\nother\nn') - # assert 'Aborting failover' in str(result.exception) + # assert 'Aborting failover' in str(result.output) # with patch('patroni.ctl.wait_for_leader', Mock(return_value = get_cluster_initialized_with_leader())): # result = runner.invoke(ctl, ['failover', 'alpha', '--dcs', '8.8.8.8'], input='leader\nother\nY') @@ -182,13 +182,19 @@ y''') '--role', 'master', ]) - assert 'mutually exclusive' in str(result.exception) + assert 'mutually exclusive' in str(result.output) with runner.isolated_filesystem(): dummy_file = open('dummy', 'w') dummy_file.write('SELECT 1') dummy_file.close() + result = runner.invoke(ctl, [ + 'query', + 'alpha' + ]) + assert 'You need to specify' in str(result.output) + result = runner.invoke(ctl, [ 'query', 'alpha', @@ -197,7 +203,7 @@ y''') '--command', 'dummy', ]) - assert 'mutually exclusive' in str(result.exception) + assert 'mutually exclusive' in str(result.output) result = runner.invoke(ctl, ['query', 'alpha', '--file', 'dummy']) @@ -206,6 +212,9 @@ y''') result = runner.invoke(ctl, ['query', 'alpha', '--command', 'SELECT 1']) assert 'mock column' in result.output + result = runner.invoke(ctl, ['query', 'alpha', '--command', 'SELECT 1', '--dbname', 'dummy', '--password', '--username', 'dummy'], input='password\n') + assert 'mock column' in result.output + @patch('patroni.ctl.get_cursor', Mock(return_value=MockConnect().cursor())) def test_query_member(self): rows = query_member(None, None, None, 'master', 'SELECT pg_is_in_recovery()') @@ -243,10 +252,10 @@ y''') '--member', 'dummy', ]) - assert 'mutually exclusive' in str(result.exception) + assert 'mutually exclusive' in str(result.output) result = runner.invoke(ctl, ['dsn', 'alpha', '--member', 'dummy']) - assert 'Can not find' in str(result.exception) + assert 'Can not find' in str(result.output) # result = runner.invoke(ctl, ['dsn', 'alpha', '--dcs', '8.8.8.8', '--role', 'replica']) # assert 'host=127.0.0.1 port=5436' in result.output @@ -270,7 +279,7 @@ y''') 'dummy', '--any', ], input='y') - assert 'not a member' in str(result.exception) + assert 'not a member' in str(result.output) with patch('requests.post', Mock(return_value=MockResponse())): result = runner.invoke(ctl, ['restart', 'alpha', '--dcs', '8.8.8.8'], input='y') @@ -283,15 +292,15 @@ y''') result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'], input='alpha\nslave') assert 'Please confirm' in result.output assert 'You are about to remove all' in result.output - assert 'You did not exactly type' in str(result.exception) + assert 'You did not exactly type' in str(result.output) result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'], input='''alpha Yes I am aware slave''') - assert 'You did not specify the current master of the cluster' in str(result.exception) + assert 'You did not specify the current master of the cluster' in str(result.output) result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'], input='beta\nleader') - assert 'Cluster names specified do not match' in str(result.exception) + assert 'Cluster names specified do not match' in str(result.output) with patch('patroni.etcd.Etcd.get_cluster', get_cluster_initialized_with_leader): result = runner.invoke(ctl, ['remove', 'alpha', '--dcs', '8.8.8.8'], @@ -305,7 +314,7 @@ leader''') input='''alpha Yes I am aware leader''') - assert 'We have not implemented this for DCS of type' in str(result.exception) + assert 'We have not implemented this for DCS of type' in str(result.output) @patch('patroni.etcd.Etcd.watch', Mock(return_value=None)) @patch('patroni.etcd.Etcd.get_cluster', Mock(return_value=get_cluster_initialized_with_leader())) diff --git a/tests/test_ha.py b/tests/test_ha.py index 9ff6547d..614f4943 100644 --- a/tests/test_ha.py +++ b/tests/test_ha.py @@ -18,7 +18,7 @@ def false(*args, **kwargs): def get_cluster(initialize, leader, members, failover): - return Cluster(initialize, leader, None, members, failover) + return Cluster(initialize, leader, 10, members, failover) def get_cluster_not_initialized_without_leader():