Acceptance tests with behave

This commit is contained in:
Alexander Kukushkin
2016-03-11 12:56:29 +01:00
parent da53452a53
commit 30d3982d25
9 changed files with 186 additions and 235 deletions
+4 -3
View File
@@ -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
-16
View File
@@ -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
-56
View File
@@ -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)
-26
View File
@@ -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)
+23 -35
View File
@@ -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()
-99
View File
@@ -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)
+55
View File
@@ -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))
+17
View File
@@ -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)
+87
View File
@@ -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))))