diff --git a/.travis.yml b/.travis.yml index 3bf864f3..073b6572 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,7 @@ language: python addons: postgresql: "9.5" env: - - BOTO_CONFIG='' + - BOTO_CONFIG='' ETCDVERSION=2.2.5 python: - "2.7" - "3.4" @@ -19,11 +19,12 @@ install: - sudo /etc/init.d/postgresql stop - if [[ $TRAVIS_PYTHON_VERSION == 2* ]]; then pip install -r requirements-py2.txt --use-mirrors; fi - if [[ $TRAVIS_PYTHON_VERSION == 3* ]]; then pip install -r requirements-py3.txt; fi - - pip install coveralls codacy-coverage + - curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz | tar xz -C . --strip=1 --wildcards --no-anchored etcd + - pip install behave coveralls codacy-coverage script: - python setup.py test - python setup.py flake8 - - bash -x acceptance_tests.sh + - PATH=.:$PATH behave after_success: - coveralls - python-codacy-coverage -r coverage.xml diff --git a/acceptance_tests.sh b/acceptance_tests.sh deleted file mode 100644 index d1fbf8d9..00000000 --- a/acceptance_tests.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -ETCDVERSION=2.2.5 - -BINDIR=bin -[ -d $BINDIR ] || mkdir $BINDIR - -export PATH=$BINDIR:$PATH - -# Add etcd -curl -L https://github.com/coreos/etcd/releases/download/v${ETCDVERSION}/etcd-v${ETCDVERSION}-linux-amd64.tar.gz | tar xz -C $BINDIR --strip=1 --wildcards --no-anchored etcd etcdctl - -sudo pip2.7 install lettuce python-Levenshtein -sudo pip2.7 install -r requirements-py2.txt - -exec lettuce diff --git a/features/basic_replication.py b/features/basic_replication.py deleted file mode 100644 index 97913fc6..00000000 --- a/features/basic_replication.py +++ /dev/null @@ -1,56 +0,0 @@ -import psycopg2 as pg -from time import sleep - -from lettuce import world, steps - - -@steps -class BasicReplicationSteps(object): - - def __init__(self, environ): - self.env = environ - - @staticmethod - def start_patroni(step, name): - '''I start (\w+)''' - return world.pctl.start(name) - - @staticmethod - def stop_patroni(step, name): - '''I shut down (\w+)''' - return world.pctl.stop(name) - - @staticmethod - def kill_patroni(step, name): - '''I kill (\w+)''' - return world.pctl.stop(name, kill=True) - - @staticmethod - def add_table(step, table_name, pg_name): - '''I add the table (\w+) to (\w+)''' - # parse the configuration file and get the port - try: - world.pctl.query(pg_name, "CREATE TABLE {0}()".format(table_name)) - except pg.Error as e: - assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e) - - @staticmethod - def table_is_present_on(step, table_name, pg_name, max_replication_delay): - '''Table (\w+) is present on (\w+) after (\d+) seconds''' - for _ in range(int(max_replication_delay)): - if world.pctl.query(pg_name, "SELECT 1 FROM {0}".format(table_name), fail_ok=True) is not None: - break - sleep(1) - else: - assert False,\ - "Table {0} is not present on {1} after {2} seconds".format(table_name, pg_name, max_replication_delay) - - @staticmethod - def check_role(step, pg_name, pg_role, max_promotion_timeout): - '''(\w+) role is the (\w+) after (\d+) seconds''' - if not world.pctl.check_role_has_changed_to(pg_name, pg_role, timeout=int(max_promotion_timeout)): - assert False,\ - "{0} role didn't change to {1} after {2} seconds".format(pg_name, pg_role, max_promotion_timeout) - - -BasicReplicationSteps(world) diff --git a/features/cascading_replication.py b/features/cascading_replication.py deleted file mode 100644 index a5733aa7..00000000 --- a/features/cascading_replication.py +++ /dev/null @@ -1,26 +0,0 @@ -from lettuce import world, steps - - -@steps -class CascadingReplicationSteps(object): - - def __init__(self, environ): - self.env = environ - - @staticmethod - def start_patroni_with_a_name_value_tag(step, name, tag_name, tag_value): - '''I configure and start (\w+) with a tag (\w+) (\w+)''' - return world.pctl.start(name, tags={tag_name: tag_value}) - - @staticmethod - def check_label(step, content, name): - '''There is a label with "(\w+)" in (\w+) data directory''' - label = world.pctl.read_label(name) - assert label == content, "{0} is not equal to {1}".format(label, content) - - @staticmethod - def write_label(step, content, name): - '''I create label with "(\w+)" in (\w+) data directory''' - world.pctl.write_label(name, content) - -CascadingReplicationSteps(world) diff --git a/features/terrain.py b/features/environment.py similarity index 87% rename from features/terrain.py rename to features/environment.py index 3a845ec5..90207ab0 100644 --- a/features/terrain.py +++ b/features/environment.py @@ -1,4 +1,3 @@ -from lettuce import world, before, after import os.path import psycopg2 import requests @@ -39,13 +38,13 @@ class PatroniController(object): return os.path.join(self.patroni_path, 'data', pg_name) def write_label(self, pg_name, content): - with open(os.path.join(self.data_dir(pg_name.encode('utf-8')), 'label'), 'w') as f: - f.write(content.encode('utf-8')) + with open(os.path.join(self.data_dir(pg_name), 'label'), 'w') as f: + f.write(content) def read_label(self, pg_name): content = None try: - with open(os.path.join(self.data_dir(pg_name.encode('utf-8')), 'label'), 'r') as f: + with open(os.path.join(self.data_dir(pg_name), 'label'), 'r') as f: content = f.read() except IOError: return None @@ -120,8 +119,8 @@ class PatroniController(object): self.stop(patroni) def create_and_set_output_directory(self, feature_name): - feature_dir = os.path.join(pctl.patroni_path, "features", "output", - feature_name.encode('utf-8').replace(' ', '_')) + feature_dir = os.path.join(self.patroni_path, "features", "output", + feature_name.replace(' ', '_')) if os.path.exists(feature_dir): shutil.rmtree(feature_dir) os.makedirs(feature_dir) @@ -137,8 +136,8 @@ class PatroniController(object): with open(patroni_config_name) as f: config = yaml.load(f) postgresql = config['postgresql'] - postgresql['name'] = pg_name.encode('utf-8') - postgresql['data_dir'] = 'data/{0}'.format(pg_name.encode('utf-8')) + postgresql['name'] = pg_name + postgresql['data_dir'] = 'data/{0}'.format(pg_name) postgresql_params = postgresql['parameters'] postgresql_params['logging_collector'] = 'on' postgresql_params['log_destination'] = 'csvlog' @@ -149,9 +148,7 @@ class PatroniController(object): postgresql_params['unix_socket_directories'] = '.' if tags: - config['tags'] = {} - for tag_name in tags: - config['tags'][tag_name.encode('utf-8')] = tags[tag_name].encode('utf-8') + config['tags'] = tags with open(patroni_config_path, 'w') as f: yaml.dump(config, f, default_flow_style=False) @@ -269,44 +266,35 @@ class EtcdController(object): # if etcd is running, but we didn't start it try: r = requests.get(EtcdController.ETCD_VERSION_URL) - running = (r and r.ok and 'etcdserver' in r.content) + running = (r and r.ok and b'etcdserver' in r.content) except requests.ConnectionError: running = False return running -pctl = PatroniController() -etcd_ctl = EtcdController(pctl.patroni_path) -# export pctl to manage patroni from scenario files -world.pctl = pctl -world.etcd_ctl = etcd_ctl - - # actions to execute on start/stop of the tests and before running invidual features -@before.all -def start_etcd(): - etcd_ctl.start() +def before_all(context): + context.pctl = PatroniController() + context.etcd_ctl = EtcdController(context.pctl.patroni_path) + context.etcd_ctl.start() try: - etcd_ctl.cleanup_service_tree() + context.etcd_ctl.cleanup_service_tree() except AssertionError: # after.all handlers won't be executed in before.all - etcd_ctl.stop_and_remove_work_directory() + context.etcd_ctl.stop_and_remove_work_directory() raise -@after.all -def stop_etcd(*args, **kwargs): - etcd_ctl.stop_and_remove_work_directory() +def after_all(context): + context.etcd_ctl.stop_and_remove_work_directory() -@before.each_feature -def make_test_output_dir(feature): +def before_feature(context, feature): """ create per-feature output directory to collect Patroni and PostgreSQL logs """ - pctl.create_and_set_output_directory(feature.name) + context.pctl.create_and_set_output_directory(feature.name) -@after.each_feature -def cleanup(*args, **kwargs): +def after_feature(context, feature): """ stop all Patronis, remove their data directory and cleanup the keys in etcd """ - pctl.stop_all() - shutil.rmtree(os.path.join(pctl.patroni_path, 'data')) - etcd_ctl.cleanup_service_tree() + context.pctl.stop_all() + shutil.rmtree(os.path.join(context.pctl.patroni_path, 'data')) + context.etcd_ctl.cleanup_service_tree() diff --git a/features/patroni_api.py b/features/patroni_api.py deleted file mode 100644 index 632bafa3..00000000 --- a/features/patroni_api.py +++ /dev/null @@ -1,99 +0,0 @@ -from datetime import datetime, timedelta -from lettuce import world, steps -import time -import pytz -import requests - - -@steps -class PatroniAPISteps(object): - - def __init__(self, environ): - self.env = environ - self.response = None - self.status_code = None - - # there is no way we can find out if the node has already - # started as a leader without checking the DCS. We cannot - # just rely on the database availability, since there is - # a short gap between the time PostgreSQL becomes available - # and Patroni assuming the leader role. - @staticmethod - def is_a_leader(step, name, time_limit): - '''(\w+) is a leader after (\d+) seconds''' - max_time = time.time() + int(time_limit) - while (world.etcd_ctl.query("leader") != name): - time.sleep(1) - if time.time() > max_time: - assert False, "{0} is not a leader in etcd after {1} seconds".format(name, time_limit) - - @staticmethod - def sleep_for_n_seconds(step, value): - '''I sleep for (\d+) seconds''' - time.sleep(int(value)) - - def do_get(self, step, url): - '''I issue a GET request to (https?://(?:\w|\.|:|/)+)''' - try: - r = requests.get(url) - except requests.exceptions.RequestException: - self.code = None - self.response = None - else: - self.status_code = r.status_code - try: - self.response = r.json() - except ValueError: - self.response = r.content - - def do_post_empty(self, step, url): - '''I issue an empty POST request to (https?://(?:\w|\.|:|/)+)''' - self.do_post(step, url, None) - - def do_post(self, step, url, data): - '''I issue a POST request to (https?://(?:\w|\.|:|/)+) with ((?:\w+=(?:\w|\.|:|-|\+|\s)+,?)+)''' - post_data = {} - if data: - post_components = data.split(',') - for pc in post_components: - if '=' in pc: - k, v = pc.split('=', 2) - post_data[k.strip()] = v.strip() - try: - r = requests.post(url, json=post_data) - except requests.exceptions.RequestException: - self.code = None - self.response = None - else: - self.status_code = r.status_code - try: - self.response = r.json() - except ValueError: - self.response = r.content - - def check_response(self, step, component, data): - '''I receive a response (\w+) (.*)''' - if component == 'code': - assert self.status_code == int(data),\ - "status code {0} != {1}, response: {2}".format(self.status_code, int(data), self.response) - elif component == 'text': - assert self.response == data.strip('"'), "response {0} does not contain {1}".format(self.response, data) - else: - assert component in self.response, "{0} is not part of the response".format(component) - assert self.response[component] == data, "{0} does not contain {1}".format(component, data) - - def replication_works(self, step, master, replica, time_limit): - '''replication works from (\w+) to (\w+) after (\d+) seconds''' - step.behave_as(""" - When I add the table test_{0} to {1} - Then table test_{0} is present on {2} after {3} seconds - """.format(int(time.time()), master, replica, time_limit)) - - def scheduld_failover(self, step, at_url, from_host, to_host, in_seconds): - '''I issue a scheduled failover at (https?://(?:\w|\.|:|/)+) from (\w+) to (\w+) in (\d+) seconds''' - step.behave_as(""" - Given I issue a POST request to {0}/failover with leader={1},candidate={2},scheduled_at={3} - """.format(at_url, from_host, to_host, datetime.now(pytz.utc) + timedelta(seconds=int(in_seconds)))) - - -PatroniAPISteps(world) diff --git a/features/steps/basic_replication.py b/features/steps/basic_replication.py new file mode 100644 index 00000000..98f5879f --- /dev/null +++ b/features/steps/basic_replication.py @@ -0,0 +1,55 @@ +import psycopg2 as pg + +from behave import step, then +from time import sleep, time + + +@step('I start {name}') +def start_patroni(context, name): + return context.pctl.start(name) + + +@step('I shut down {name}') +def stop_patroni(context, name): + return context.pctl.stop(name) + + +@step('I kill {name}') +def kill_patroni(context, name): + return context.pctl.stop(name, kill=True) + + +@step('I add the table {table_name} to {pg_name}') +def add_table(context, table_name, pg_name): + # parse the configuration file and get the port + try: + context.pctl.query(pg_name, "CREATE TABLE {0}()".format(table_name)) + except pg.Error as e: + assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e) + + +@then('Table {table_name} is present on {pg_name} after {max_replication_delay} seconds') +def table_is_present_on(context, table_name, pg_name, max_replication_delay): + for _ in range(int(max_replication_delay)): + if context.pctl.query(pg_name, "SELECT 1 FROM {0}".format(table_name), fail_ok=True) is not None: + break + sleep(1) + else: + assert False,\ + "Table {0} is not present on {1} after {2} seconds".format(table_name, pg_name, max_replication_delay) + + +@then('{pg_name} role is the {pg_role} after {max_promotion_timeout} seconds') +def check_role(context, pg_name, pg_role, max_promotion_timeout): + if not context.pctl.check_role_has_changed_to(pg_name, pg_role, timeout=int(max_promotion_timeout)): + assert False,\ + "{0} role didn't change to {1} after {2} seconds".format(pg_name, pg_role, max_promotion_timeout) + + +@step('replication works from {master} to {replica} after {time_limit} seconds') +@then('replication works from {master} to {replica} after {time_limit} seconds') +def replication_works(context, master, replica, time_limit): + context.execute_steps(""" + When I add the table test_{0} to {1} + Then table test_{0} is present on {2} after {3} seconds + """.format(int(time()), master, replica, time_limit)) diff --git a/features/steps/cascading_replication.py b/features/steps/cascading_replication.py new file mode 100644 index 00000000..07d8fbd2 --- /dev/null +++ b/features/steps/cascading_replication.py @@ -0,0 +1,17 @@ +from behave import step, then + + +@step('I configure and start {name} with a tag {tag_name} {tag_value}') +def start_patroni_with_a_name_value_tag(context, name, tag_name, tag_value): + return context.pctl.start(name, tags={tag_name: tag_value}) + + +@then('There is a label with "{content}" in {name} data directory') +def check_label(context, content, name): + label = context.pctl.read_label(name) + assert label == content, "{0} is not equal to {1}".format(label, content) + + +@step('I create label with "{content}" in {name} data directory') +def write_label(context, content, name): + context.pctl.write_label(name, content) diff --git a/features/steps/patroni_api.py b/features/steps/patroni_api.py new file mode 100644 index 00000000..309a20af --- /dev/null +++ b/features/steps/patroni_api.py @@ -0,0 +1,87 @@ +import time +import pytz +import requests + +from datetime import datetime, timedelta +from behave import step, then + + +# there is no way we can find out if the node has already +# started as a leader without checking the DCS. We cannot +# just rely on the database availability, since there is +# a short gap between the time PostgreSQL becomes available +# and Patroni assuming the leader role. +@step('{name} is a leader after {time_limit} seconds') +@then('{name} is a leader after {time_limit} seconds') +def is_a_leader(context, name, time_limit): + max_time = time.time() + int(time_limit) + while (context.etcd_ctl.query("leader") != name): + time.sleep(1) + if time.time() > max_time: + assert False, "{0} is not a leader in etcd after {1} seconds".format(name, time_limit) + + +@step('I sleep for {value} seconds') +def sleep_for_n_seconds(context, value): + time.sleep(int(value)) + + +@step('I issue a GET request to {url}') +def do_get(context, url): + try: + r = requests.get(url) + except requests.exceptions.RequestException: + context.status_code = None + context.response = None + else: + context.status_code = r.status_code + try: + context.response = r.json() + except ValueError: + context.response = r.content.decode('utf-8') + + +@step('I issue an empty POST request to {url}') +def do_post_empty(context, url): + do_post(context, url, None) + + +@step('I issue a POST request to {url} with {data}') +def do_post(context, url, data): + post_data = {} + if data: + post_components = data.split(',') + for pc in post_components: + if '=' in pc: + k, v = pc.split('=', 2) + post_data[k.strip()] = v.strip() + try: + r = requests.post(url, json=post_data) + except requests.exceptions.RequestException: + context.status_code = None + context.response = None + else: + context.status_code = r.status_code + try: + context.response = r.json() + except ValueError: + context.response = r.content.decode('utf-8') + + +@then('I receive a response {component} {data}') +def check_response(context, component, data): + if component == 'code': + assert context.status_code == int(data),\ + "status code {0} != {1}, response: {2}".format(context.status_code, int(data), context.response) + elif component == 'text': + assert context.response == data.strip('"'), "response {0} does not contain {1}".format(context.response, data) + else: + assert component in context.response, "{0} is not part of the response".format(component) + assert context.response[component] == data, "{0} does not contain {1}".format(component, data) + + +@step('I issue a scheduled failover at {at_url} from {from_host} to {to_host} in {in_seconds} seconds') +def scheduld_failover(context, at_url, from_host, to_host, in_seconds): + context.execute_steps(""" + Given I issue a POST request to {0}/failover with leader={1},candidate={2},scheduled_at={3} + """.format(at_url, from_host, to_host, datetime.now(pytz.utc) + timedelta(seconds=int(in_seconds))))